@idelio/contracts 0.1.0-beta.0

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.
@@ -0,0 +1,3026 @@
1
+ # Idelio Public API - OpenAPI 3.1 (source of truth)
2
+ # Full surface: 63 routes across 15 resource groups (see docs/API Specification v1.1).
3
+ # This file starts with the core generation slice; extend additively only. One
4
+ # route has ever been removed: POST /webhooks/paddle, when Paddle was retired
5
+ # (#334). It was a signature-gated inbound callback that only Paddle itself
6
+ # called, so no API client lost anything that worked - the additive-only rule
7
+ # protects clients of this API, not a retired provider's callbacks.
8
+ openapi: 3.1.0
9
+ info:
10
+ title: Idelio API
11
+ version: 1.0.0
12
+ description: >
13
+ AI-native branding platform. Bearer auth (Clerk JWT or workspace API key
14
+ sk_live_/sk_test_). Idempotency-Key mandatory on paid generation routes.
15
+ Uniform error envelope on every 4xx/5xx. Cursor pagination on all lists.
16
+ servers:
17
+ - url: https://api.idelio.pro/v1
18
+ security:
19
+ - bearerAuth: []
20
+ - apiKeyAuth: []
21
+ paths:
22
+ /me:
23
+ get:
24
+ operationId: getMe
25
+ summary: Return the authenticated user, their workspace and effective role.
26
+ responses:
27
+ "200":
28
+ description: User + workspace context
29
+ content:
30
+ application/json:
31
+ schema: { $ref: "#/components/schemas/MeResponse" }
32
+ /me/onboarding:
33
+ post:
34
+ operationId: completeOnboarding
35
+ summary: Record that the authenticated membership finished onboarding.
36
+ description: >-
37
+ Idempotent. A repeat returns the stored timestamp rather than moving it,
38
+ so a retry or a second tab cannot rewrite when a user was onboarded.
39
+ Takes no request body - the only fact is that it happened, and the
40
+ timestamp is the server's. Refused for an API key, which acts for the
41
+ workspace rather than for a person and has no membership to settle.
42
+ responses:
43
+ "200":
44
+ description: The onboarding completion timestamp in force.
45
+ content:
46
+ application/json:
47
+ schema:
48
+ type: object
49
+ required: [onboardingCompletedAt]
50
+ properties:
51
+ onboardingCompletedAt: { type: string, format: date-time }
52
+ "422":
53
+ description: Authenticated with an API key, which has no membership.
54
+ content:
55
+ application/json:
56
+ schema: { $ref: "#/components/schemas/Error" }
57
+ /workspace:
58
+ get:
59
+ operationId: getWorkspace
60
+ summary: Retrieve the current workspace.
61
+ responses:
62
+ "200":
63
+ description: Workspace object
64
+ headers:
65
+ X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
66
+ X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
67
+ X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
68
+ content:
69
+ application/json:
70
+ schema:
71
+ type: object
72
+ properties:
73
+ workspace: { $ref: "#/components/schemas/Workspace" }
74
+ "429": { $ref: "#/components/responses/RateLimited" }
75
+ patch:
76
+ operationId: updateWorkspace
77
+ description: Available to a signed-in app session with at least the admin role. An API key receives 403 `session_required`.
78
+ security: [{ bearerAuth: [] }]
79
+ summary: Update workspace name and settings.
80
+ parameters:
81
+ - name: Idempotency-Key
82
+ in: header
83
+ required: true
84
+ schema: { type: string }
85
+ requestBody:
86
+ required: true
87
+ content:
88
+ application/json:
89
+ schema: { $ref: "#/components/schemas/UpdateWorkspaceRequest" }
90
+ responses:
91
+ "200":
92
+ description: Updated workspace
93
+ headers:
94
+ X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
95
+ X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
96
+ X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
97
+ content:
98
+ application/json:
99
+ schema:
100
+ type: object
101
+ properties:
102
+ workspace: { $ref: "#/components/schemas/Workspace" }
103
+ "422": { description: Invalid patch }
104
+ "429": { $ref: "#/components/responses/RateLimited" }
105
+ /workspace/members:
106
+ get:
107
+ operationId: listMembers
108
+ summary: List members and their roles.
109
+ parameters:
110
+ - { name: cursor, in: query, schema: { type: string } }
111
+ - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } }
112
+ responses:
113
+ "200":
114
+ description: Paginated members
115
+ headers:
116
+ X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
117
+ X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
118
+ X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
119
+ content:
120
+ application/json:
121
+ schema:
122
+ type: object
123
+ properties:
124
+ data:
125
+ type: array
126
+ items: { $ref: "#/components/schemas/Member" }
127
+ next_cursor: { type: string, nullable: true }
128
+ has_more: { type: boolean }
129
+ "429": { $ref: "#/components/responses/RateLimited" }
130
+ /workspace/invitations:
131
+ post:
132
+ operationId: inviteMember
133
+ description: Available to a signed-in app session with at least the admin role. An API key receives 403 `session_required`.
134
+ security: [{ bearerAuth: [] }]
135
+ summary: Invite a user by email with a role.
136
+ parameters:
137
+ - name: Idempotency-Key
138
+ in: header
139
+ required: true
140
+ schema: { type: string }
141
+ requestBody:
142
+ required: true
143
+ content:
144
+ application/json:
145
+ schema: { $ref: "#/components/schemas/InviteMemberRequest" }
146
+ responses:
147
+ "201":
148
+ description: Invitation created
149
+ headers:
150
+ X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
151
+ X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
152
+ X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
153
+ content:
154
+ application/json:
155
+ schema:
156
+ type: object
157
+ properties:
158
+ invitation:
159
+ type: object
160
+ properties:
161
+ email: { type: string }
162
+ role: { type: string, enum: [admin, member] }
163
+ status: { type: string }
164
+ "409": { description: Already a member }
165
+ "422": { description: Invalid request }
166
+ "429": { $ref: "#/components/responses/RateLimited" }
167
+ /workspace/members/{userId}:
168
+ patch:
169
+ operationId: changeMemberRole
170
+ description: Available to a signed-in app session with at least the admin role. An API key receives 403 `session_required`.
171
+ security: [{ bearerAuth: [] }]
172
+ summary: Change a member's role.
173
+ parameters:
174
+ - { name: userId, in: path, required: true, schema: { type: string } }
175
+ requestBody:
176
+ required: true
177
+ content:
178
+ application/json:
179
+ schema: { $ref: "#/components/schemas/ChangeMemberRoleRequest" }
180
+ responses:
181
+ "200":
182
+ description: Updated member
183
+ headers:
184
+ X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
185
+ X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
186
+ X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
187
+ content:
188
+ application/json:
189
+ schema:
190
+ type: object
191
+ properties:
192
+ member: { $ref: "#/components/schemas/Member" }
193
+ "404": { description: Member not found }
194
+ "409": { description: Would leave the workspace without an owner }
195
+ "422": { description: Invalid request }
196
+ "429": { $ref: "#/components/responses/RateLimited" }
197
+ delete:
198
+ operationId: removeMember
199
+ description: Available to a signed-in app session with at least the admin role. An API key receives 403 `session_required`.
200
+ security: [{ bearerAuth: [] }]
201
+ summary: Remove a member from the workspace.
202
+ parameters:
203
+ - { name: userId, in: path, required: true, schema: { type: string } }
204
+ responses:
205
+ "204": { description: Removed }
206
+ "404": { description: Member not found }
207
+ "409": { description: Would leave the workspace without an owner }
208
+ "429": { $ref: "#/components/responses/RateLimited" }
209
+ /brands:
210
+ get:
211
+ operationId: listBrands
212
+ summary: List brands in the workspace.
213
+ parameters:
214
+ - { name: status, in: query, schema: { type: string, enum: [draft, generating, ready, failed] } }
215
+ - { name: q, in: query, schema: { type: string } }
216
+ - { name: cursor, in: query, schema: { type: string } }
217
+ - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } }
218
+ responses:
219
+ "200":
220
+ description: Paginated brands
221
+ headers:
222
+ X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
223
+ X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
224
+ X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
225
+ content:
226
+ application/json:
227
+ schema:
228
+ type: object
229
+ properties:
230
+ data:
231
+ type: array
232
+ items: { $ref: "#/components/schemas/Brand" }
233
+ next_cursor: { type: string, nullable: true }
234
+ has_more: { type: boolean }
235
+ "429": { $ref: "#/components/responses/RateLimited" }
236
+ post:
237
+ operationId: createBrand
238
+ summary: Generate a new brand from a prompt or template (reserves credits, starts a durable workflow).
239
+ parameters:
240
+ - name: Idempotency-Key
241
+ in: header
242
+ required: true
243
+ schema: { type: string }
244
+ requestBody:
245
+ required: true
246
+ content:
247
+ application/json:
248
+ schema: { $ref: "#/components/schemas/CreateBrandRequest" }
249
+ responses:
250
+ "202":
251
+ description: Job accepted
252
+ headers:
253
+ X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
254
+ X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
255
+ X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
256
+ content:
257
+ application/json:
258
+ schema: { $ref: "#/components/schemas/JobEnvelope" }
259
+ "402": { description: Insufficient credits }
260
+ "409": { description: This workspace already has its plan's worth of generations running - `code` is `too_many_running_jobs` }
261
+ "422":
262
+ description: >-
263
+ Invalid request. `code` is `validation_failed`, or
264
+ `required_assets_missing` when an explicit `assets` list omits a
265
+ required kind (the message names which); `param` names the
266
+ failing field.
267
+ "429": { $ref: "#/components/responses/RateLimited" }
268
+ /brands/surprise:
269
+ post:
270
+ operationId: surpriseBrief
271
+ summary: Generate a random brand idea to seed the creator's Describe step ("Surprise me") - free, not persisted.
272
+ responses:
273
+ "200":
274
+ description: A freshly generated random brief
275
+ content:
276
+ application/json:
277
+ schema: { $ref: "#/components/schemas/SurpriseBriefResponse" }
278
+ "429": { $ref: "#/components/responses/RateLimited" }
279
+ /brands/directions:
280
+ post:
281
+ operationId: brandCreativeDirections
282
+ summary: Shortlist creative directions against a brief - free, not persisted.
283
+ description: >-
284
+ Ranks the palette, typography and logo catalogs against the founder's
285
+ own description so the Customize step suggests directions that suit
286
+ their brand instead of a fixed order. Returns catalog ids only, never
287
+ colours or family names, and the API validates every id against its own
288
+ catalog before answering. Free and not persisted, so no
289
+ Idempotency-Key; rate limited like /brands/names. A failure here is not
290
+ an error the user needs to see - the client falls back to the catalog
291
+ order.
292
+ requestBody:
293
+ required: true
294
+ content:
295
+ application/json:
296
+ schema: { $ref: "#/components/schemas/CreativeDirectionsRequest" }
297
+ responses:
298
+ "200":
299
+ description: A ranked shortlist per axis, padded from the catalog
300
+ content:
301
+ application/json:
302
+ schema: { $ref: "#/components/schemas/CreativeDirectionsShortlist" }
303
+ "422": { description: Invalid request body - the failing field is named in `param` }
304
+ "429": { $ref: "#/components/responses/RateLimited" }
305
+ "503": { description: The shortlist could not be produced - retryable }
306
+ /brands/names:
307
+ post:
308
+ operationId: brandNameIdeas
309
+ summary: Generate brand name ideas for the creator's name generator - free, not persisted.
310
+ description: >-
311
+ Returns six brand name candidates in the requested style, each with a
312
+ one-line hint explaining why it works. Pass the founder's own keyword
313
+ and description to keep the ideas about their actual business. Free
314
+ and not persisted, so no Idempotency-Key; rate limited like
315
+ /brands/surprise.
316
+ requestBody:
317
+ required: true
318
+ content:
319
+ application/json:
320
+ schema: { $ref: "#/components/schemas/NameIdeasRequest" }
321
+ responses:
322
+ "200":
323
+ description: A freshly generated set of name ideas
324
+ content:
325
+ application/json:
326
+ schema: { $ref: "#/components/schemas/NameIdeasResponse" }
327
+ "422": { description: Invalid request body - the failing field is named in `param` }
328
+ "429": { $ref: "#/components/responses/RateLimited" }
329
+ "503": { description: The generator could not produce names - retryable }
330
+ /brands/{id}:
331
+ get:
332
+ operationId: getBrand
333
+ summary: Retrieve a brand with its head version summary.
334
+ parameters:
335
+ - { name: id, in: path, required: true, schema: { type: string } }
336
+ responses:
337
+ "200":
338
+ description: Brand object
339
+ headers:
340
+ X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
341
+ X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
342
+ X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
343
+ content:
344
+ application/json:
345
+ schema:
346
+ type: object
347
+ properties:
348
+ brand: { $ref: "#/components/schemas/Brand" }
349
+ "404": { description: Not found }
350
+ "429": { $ref: "#/components/responses/RateLimited" }
351
+ patch:
352
+ operationId: updateBrand
353
+ summary: Rename a brand, change its slug, or update generation locks.
354
+ parameters:
355
+ - { name: id, in: path, required: true, schema: { type: string } }
356
+ requestBody:
357
+ required: true
358
+ content:
359
+ application/json:
360
+ schema: { $ref: "#/components/schemas/UpdateBrandRequest" }
361
+ responses:
362
+ "200":
363
+ description: Updated brand
364
+ headers:
365
+ X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
366
+ X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
367
+ X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
368
+ content:
369
+ application/json:
370
+ schema:
371
+ type: object
372
+ properties:
373
+ brand: { $ref: "#/components/schemas/Brand" }
374
+ "404": { description: Not found }
375
+ "409": { description: Slug already in use in this workspace }
376
+ "422": { description: Invalid patch }
377
+ "429": { $ref: "#/components/responses/RateLimited" }
378
+ delete:
379
+ operationId: deleteBrand
380
+ summary: Soft-delete a brand (never hard-deleted).
381
+ parameters:
382
+ - { name: id, in: path, required: true, schema: { type: string } }
383
+ responses:
384
+ "204": { description: Deleted }
385
+ "404": { description: Not found }
386
+ "429": { $ref: "#/components/responses/RateLimited" }
387
+ /brands/{id}/assets:
388
+ get:
389
+ operationId: listBrandAssets
390
+ summary: List assets belonging to a brand.
391
+ parameters:
392
+ - { name: id, in: path, required: true, schema: { type: string } }
393
+ - { name: kind, in: query, schema: { type: string } }
394
+ - { name: cursor, in: query, schema: { type: string } }
395
+ - name: limit
396
+ in: query
397
+ description: >-
398
+ Page size, 1-100. A brand with the whole catalogue holds more than
399
+ one default page, so follow `next_cursor` or raise the limit.
400
+ schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
401
+ responses:
402
+ "200":
403
+ description: Paginated assets
404
+ headers:
405
+ X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
406
+ X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
407
+ X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
408
+ content:
409
+ application/json:
410
+ schema:
411
+ type: object
412
+ properties:
413
+ data:
414
+ type: array
415
+ items: { $ref: "#/components/schemas/Asset" }
416
+ next_cursor: { type: string, nullable: true }
417
+ has_more: { type: boolean }
418
+ "404": { description: Not found }
419
+ "429": { $ref: "#/components/responses/RateLimited" }
420
+ post:
421
+ operationId: expandBrand
422
+ summary: Expand a brand - generate additional assets from its frozen head DNA (every asset kind except logo, which has its own generate/approve path). custom_image requires custom_image_prompt and is never combined with other kinds in the same request; color_palette/font_pairing are likewise never combined with any other kind. mockup and hero_image are studio/elite only - flash has no model route for them.
423
+ parameters:
424
+ - { name: id, in: path, required: true, schema: { type: string } }
425
+ - { name: Idempotency-Key, in: header, required: true, schema: { type: string } }
426
+ requestBody:
427
+ required: true
428
+ content:
429
+ application/json:
430
+ schema: { $ref: "#/components/schemas/ExpandBrandRequest" }
431
+ responses:
432
+ "202":
433
+ description: GenerationJob object (queued)
434
+ headers:
435
+ X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
436
+ X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
437
+ X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
438
+ content:
439
+ application/json:
440
+ schema: { $ref: "#/components/schemas/JobEnvelope" }
441
+ "402": { description: Insufficient credits }
442
+ "404": { description: Not found }
443
+ "409": { description: "Brand not ready, one of the requested asset kinds already exists, or this workspace already has its plan's worth of generations running (`code` is `too_many_running_jobs`)" }
444
+ "422": { description: An asset kind cannot be expanded yet, or a templated/mockup asset was requested on the flash tier }
445
+ "429": { $ref: "#/components/responses/RateLimited" }
446
+ /brands/{id}/refine:
447
+ post:
448
+ operationId: refineBrandDna
449
+ summary: Free-text Creative Director chat edit to the brand's DNA (strategy/palette/typography); forks a new BrandVersion.
450
+ parameters:
451
+ - { name: id, in: path, required: true, schema: { type: string } }
452
+ - { name: Idempotency-Key, in: header, required: true, schema: { type: string } }
453
+ requestBody:
454
+ required: true
455
+ content:
456
+ application/json:
457
+ schema: { $ref: "#/components/schemas/BrandRefineRequest" }
458
+ responses:
459
+ "202":
460
+ description: GenerationJob object (queued)
461
+ headers:
462
+ X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
463
+ X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
464
+ X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
465
+ content:
466
+ application/json:
467
+ schema: { $ref: "#/components/schemas/JobEnvelope" }
468
+ "402": { description: Insufficient credits }
469
+ "404": { description: Not found }
470
+ "409": { description: "Brand not ready, or this workspace already has its plan's worth of generations running (`code` is `too_many_running_jobs`)" }
471
+ "422": { description: Validation failed }
472
+ "429": { $ref: "#/components/responses/RateLimited" }
473
+ /brands/{id}/logo:
474
+ get:
475
+ operationId: getBrandLogo
476
+ summary: Redirect to the brand's logo file, as PNG or SVG.
477
+ parameters:
478
+ - { name: id, in: path, required: true, schema: { type: string } }
479
+ - { name: format, in: query, schema: { type: string, enum: [png, svg], default: png } }
480
+ - name: size
481
+ in: query
482
+ description: >-
483
+ Kept for older brands only. The mark, wordmark and lockup are each
484
+ served as the file generation wrote, at its native resolution. A
485
+ brand from before the raster mark holds an SVG-only logo, and for
486
+ it a PNG request is answered with the favicon (`size` <= 64) or
487
+ the app icon. Fixed-size icons are their own assets: use
488
+ GET /assets/{id}/download with `size`.
489
+ schema: { type: integer, default: 512 }
490
+ - name: variant
491
+ in: query
492
+ description: >-
493
+ `mark` is the symbol alone (the `logo` asset), `wordmark` the
494
+ name alone (the `logo_wordmark` asset, or the retired `wordmark`
495
+ kind older brands hold, which is PNG-only), and `lockup` the two
496
+ arranged together (`logo_alternative`) in whichever arrangement
497
+ the brand pinned in the creator - horizontal when it pinned none.
498
+ Brands generated before lockups were produced answer 422 for
499
+ `lockup`.
500
+ schema: { type: string, enum: [mark, wordmark, lockup], default: mark }
501
+ responses:
502
+ "302": { description: Redirect to signed CDN URL }
503
+ "404": { description: Not found }
504
+ "409": { description: Brand not ready }
505
+ "422": { description: Unsupported format for this asset, or a lockup this brand does not have }
506
+ /brands/{id}/palette:
507
+ get:
508
+ operationId: getBrandPalette
509
+ summary: Export the palette as design tokens (inline).
510
+ parameters:
511
+ - { name: id, in: path, required: true, schema: { type: string } }
512
+ - { name: format, in: query, schema: { type: string, enum: [json, css], default: json } }
513
+ responses:
514
+ "200":
515
+ description: Tokens or CSS variables
516
+ content:
517
+ application/json:
518
+ schema:
519
+ type: object
520
+ properties:
521
+ brand_id: { type: string }
522
+ colors: { type: object, additionalProperties: { type: string } }
523
+ css: { type: string }
524
+ "404": { description: Not found }
525
+ "409": { description: Brand not ready }
526
+ /brands/{id}/tokens:
527
+ get:
528
+ operationId: getBrandTokens
529
+ summary: Full W3C design-token document (inline).
530
+ parameters:
531
+ - { name: id, in: path, required: true, schema: { type: string } }
532
+ responses:
533
+ "200":
534
+ description: tokens.json
535
+ content:
536
+ application/json:
537
+ schema:
538
+ type: object
539
+ properties:
540
+ tokens: { type: object }
541
+ "404": { description: Not found }
542
+ "409": { description: Brand not ready }
543
+ /brands/{id}/dna:
544
+ get:
545
+ operationId: getBrandDna
546
+ summary: The frozen creative genome (brief + strategy) the brand reads from.
547
+ parameters:
548
+ - { name: id, in: path, required: true, schema: { type: string } }
549
+ responses:
550
+ "200":
551
+ description: Brand DNA
552
+ content:
553
+ application/json:
554
+ schema:
555
+ type: object
556
+ properties:
557
+ dna: { $ref: "#/components/schemas/BrandDna" }
558
+ "404": { description: Not found }
559
+ "409": { description: Brand not ready }
560
+ /brands/{id}/versions:
561
+ get:
562
+ operationId: listBrandVersions
563
+ summary: Version history (git-commit semantics) for the brand, newest first.
564
+ parameters:
565
+ - { name: id, in: path, required: true, schema: { type: string } }
566
+ responses:
567
+ "200":
568
+ description: Brand versions
569
+ content:
570
+ application/json:
571
+ schema:
572
+ type: object
573
+ properties:
574
+ data:
575
+ type: array
576
+ items: { $ref: "#/components/schemas/BrandVersionSummary" }
577
+ "404": { description: Not found }
578
+ /brands/{id}/suggestions:
579
+ get:
580
+ operationId: getBrandSuggestions
581
+ summary: This week's Creative-Director suggestions for the dashboard - computed weekly per brand, cached.
582
+ parameters:
583
+ - { name: id, in: path, required: true, schema: { type: string } }
584
+ responses:
585
+ "200":
586
+ description: Suggestions
587
+ content:
588
+ application/json:
589
+ schema:
590
+ type: object
591
+ properties:
592
+ items:
593
+ type: array
594
+ items: { $ref: "#/components/schemas/BrandSuggestionItem" }
595
+ "404": { description: Not found }
596
+ "409": { description: Brand not ready }
597
+ /brands/{id}/suggestions/{kind}/generate:
598
+ post:
599
+ operationId: generateBrandSuggestion
600
+ summary: Start generating one of this week's suggestions - delegates entirely to POST /brands/{id}/assets.
601
+ parameters:
602
+ - { name: id, in: path, required: true, schema: { type: string } }
603
+ - { name: kind, in: path, required: true, schema: { type: string } }
604
+ - { name: Idempotency-Key, in: header, required: true, schema: { type: string } }
605
+ responses:
606
+ "202":
607
+ description: GenerationJob object (queued) - poll GET /jobs/{id} or GET /brands/{id}/suggestions for status
608
+ content:
609
+ application/json:
610
+ schema:
611
+ type: object
612
+ properties:
613
+ job_id: { type: string }
614
+ "404": { description: Suggestion not found }
615
+ "409": { description: "Already generating, or brand not ready, or this workspace already has its plan's worth of generations running (`code` is `too_many_running_jobs`), or the suggestion is a custom asset idea (`code` is `suggestion_needs_review`) - generate those through POST /brands/{id}/assets once the user has seen the prompt" }
616
+ /suggestions:
617
+ get:
618
+ operationId: listSuggestions
619
+ summary: The dashboard's cross-brand suggestions - up to 3 items from the 3 most recently active ready brands, mixed round-robin. Never 404/409; a workspace with nothing to suggest gets an empty list. A brand that owns every catalogue kind it can be offered contributes custom asset ideas (kind custom_image) instead.
620
+ responses:
621
+ "200":
622
+ description: Suggestions
623
+ content:
624
+ application/json:
625
+ schema:
626
+ type: object
627
+ required: [items]
628
+ properties:
629
+ items:
630
+ type: array
631
+ maxItems: 3
632
+ items: { $ref: "#/components/schemas/WorkspaceSuggestionItem" }
633
+ /brands/{id}/versions/restore:
634
+ post:
635
+ operationId: restoreBrandVersion
636
+ summary: Restore a prior brand DNA version as the current version (creates a new version, like git revert - nothing is edited in place). Asset versions are untouched.
637
+ parameters:
638
+ - { name: id, in: path, required: true, schema: { type: string } }
639
+ - { name: Idempotency-Key, in: header, required: true, schema: { type: string } }
640
+ requestBody:
641
+ required: true
642
+ content:
643
+ application/json:
644
+ schema: { $ref: "#/components/schemas/RestoreRequest" }
645
+ responses:
646
+ "201":
647
+ description: Restored
648
+ content:
649
+ application/json:
650
+ schema:
651
+ type: object
652
+ properties:
653
+ brand: { $ref: "#/components/schemas/Brand" }
654
+ "404": { description: Not found }
655
+ /brands/{id}/export:
656
+ get:
657
+ operationId: exportBrandKit
658
+ summary: Package the full brand kit as a ZIP (async, always repackaged fresh).
659
+ parameters:
660
+ - { name: id, in: path, required: true, schema: { type: string } }
661
+ - { name: include, in: query, schema: { type: string }, description: "Comma-separated asset kinds, e.g. logo,color_palette. Omit for all." }
662
+ responses:
663
+ "202":
664
+ description: GenerationJob object (queued) - poll GET /jobs/{id} for download_url
665
+ content:
666
+ application/json:
667
+ schema: { $ref: "#/components/schemas/JobEnvelope" }
668
+ "404": { description: Not found }
669
+ "409": { description: Brand not ready }
670
+ /brands/{id}/bundle:
671
+ get:
672
+ operationId: getBrandBundle
673
+ summary: Repo-ready package (tokens, CSS/framework config, logo/favicon) for a target toolchain - deterministic per brand+target; 302 if already built, 202 otherwise.
674
+ parameters:
675
+ - { name: id, in: path, required: true, schema: { type: string } }
676
+ - { name: target, in: query, schema: { type: string, enum: [tokens, next, vite, expo], default: tokens } }
677
+ - { name: version, in: query, schema: { type: string }, description: "Not yet supported - any value returns 422." }
678
+ - { name: redirect, in: query, schema: { type: boolean, default: true } }
679
+ responses:
680
+ "200":
681
+ description: Signed CDN URL as JSON (only when redirect=false and the archive already exists)
682
+ content:
683
+ application/json:
684
+ schema:
685
+ type: object
686
+ required: [url]
687
+ properties: { url: { type: string } }
688
+ "302": { description: Redirect to a signed CDN URL for the already-built archive (default) }
689
+ "202":
690
+ description: Packaging job started - poll GET /jobs/{id} for download_url
691
+ content:
692
+ application/json:
693
+ schema: { $ref: "#/components/schemas/JobEnvelope" }
694
+ "404": { description: Not found }
695
+ "409": { description: Brand not ready }
696
+ "422": { description: Invalid target, or version pinning requested (not supported) }
697
+ /brands/{id}/guidelines:
698
+ get:
699
+ operationId: getBrandGuidelines
700
+ summary: Download the brand guidelines PDF.
701
+ parameters:
702
+ - { name: id, in: path, required: true, schema: { type: string } }
703
+ responses:
704
+ "302": { description: Redirect to signed CDN URL }
705
+ "404": { description: Not found - the brand hasn't expanded this asset yet }
706
+ "409": { description: Brand not ready }
707
+ /jobs:
708
+ get:
709
+ operationId: listJobs
710
+ summary: List generation jobs in the workspace.
711
+ parameters:
712
+ - { name: status, in: query, schema: { type: string, enum: [queued, running, completed, failed, canceled] } }
713
+ - { name: brand_id, in: query, schema: { type: string } }
714
+ - { name: cursor, in: query, schema: { type: string } }
715
+ responses:
716
+ "200":
717
+ description: Paginated jobs
718
+ headers:
719
+ X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
720
+ X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
721
+ X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
722
+ content:
723
+ application/json:
724
+ schema:
725
+ type: object
726
+ properties:
727
+ data:
728
+ type: array
729
+ items: { $ref: "#/components/schemas/GenerationJob" }
730
+ next_cursor: { type: string, nullable: true }
731
+ has_more: { type: boolean }
732
+ "429": { $ref: "#/components/responses/RateLimited" }
733
+ /jobs/{id}:
734
+ get:
735
+ operationId: getJob
736
+ summary: Get job status and per-step progress.
737
+ parameters:
738
+ - { name: id, in: path, required: true, schema: { type: string } }
739
+ responses:
740
+ "200":
741
+ description: GenerationJob object
742
+ headers:
743
+ X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
744
+ X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
745
+ X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
746
+ content:
747
+ application/json:
748
+ schema: { $ref: "#/components/schemas/JobEnvelope" }
749
+ "404": { description: Not found }
750
+ "429": { $ref: "#/components/responses/RateLimited" }
751
+ /jobs/{id}/events:
752
+ get:
753
+ operationId: streamJobEvents
754
+ summary: Subscribe to the job's SSE event stream (text/event-stream).
755
+ description: >-
756
+ Emits `stream.connected`, then `job.step.started`/`job.step.completed`
757
+ (skipped steps carry `"skipped": true`), interleaved with `asset.ready`
758
+ (payload `{asset_id, kind}`) whenever a generated asset becomes
759
+ available mid-pipeline - e.g. the logo concepts a picker step needs to
760
+ render, before the job as a whole finishes. A single asset can
761
+ independently emit `asset.failed` (`{kind, error}`) or
762
+ `asset.insufficient_credits` (`{kind, required, available}`) without
763
+ ending the stream, and `asset.retrying` (`{kind}`) when a retry signal
764
+ is accepted. `assets.pending_retry` (`{brand_id, kinds}`) is a one-off
765
+ nudge, a couple of hours in, that those kinds are still waiting to be
766
+ retried before the free in-job retry window closes. The stream finally
767
+ ends with `job.completed`,
768
+ `job.completed_with_errors` (`{failed_kinds}`, when one or more assets
769
+ were never resolved), or `job.failed`, after which the stream closes.
770
+ Event ids are Redis stream entry ids; reconnect with Last-Event-ID to
771
+ resume without gaps. Comment lines (`: ping`) are heartbeats. Clients
772
+ must close the EventSource after a terminal event - the server ends
773
+ the stream and a reconnect would only replay it.
774
+ parameters:
775
+ - { name: id, in: path, required: true, schema: { type: string } }
776
+ - { name: Last-Event-ID, in: header, schema: { type: string } }
777
+ responses:
778
+ "200": { description: text/event-stream }
779
+ "404": { description: Not found }
780
+ /jobs/{id}/cancel:
781
+ post:
782
+ operationId: cancelJob
783
+ summary: Cancel a running job; the reservation is refunded on the ledger.
784
+ parameters:
785
+ - { name: id, in: path, required: true, schema: { type: string } }
786
+ responses:
787
+ "200":
788
+ description: GenerationJob object (canceling/canceled)
789
+ headers:
790
+ X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
791
+ X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
792
+ X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
793
+ content:
794
+ application/json:
795
+ schema: { $ref: "#/components/schemas/JobEnvelope" }
796
+ "404": { description: Not found }
797
+ "409": { description: Job already finished }
798
+ "429": { $ref: "#/components/responses/RateLimited" }
799
+ /jobs/{id}/logo-selection:
800
+ post:
801
+ operationId: selectLogoConcept
802
+ summary: Approve one of the generated logo concepts; resumes the paused workflow.
803
+ parameters:
804
+ - { name: id, in: path, required: true, schema: { type: string } }
805
+ requestBody:
806
+ required: true
807
+ content:
808
+ application/json:
809
+ schema: { $ref: "#/components/schemas/LogoSelectionRequest" }
810
+ responses:
811
+ "200":
812
+ description: >-
813
+ GenerationJob object (signal sent; the logo step transitions to
814
+ completed asynchronously once the workflow processes the
815
+ selection)
816
+ headers:
817
+ X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
818
+ X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
819
+ X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
820
+ content:
821
+ application/json:
822
+ schema: { $ref: "#/components/schemas/JobEnvelope" }
823
+ "404": { description: Not found }
824
+ "409": { description: Job is not currently awaiting a logo selection }
825
+ "429": { $ref: "#/components/responses/RateLimited" }
826
+ /jobs/{id}/logo-concepts/{assetVersionId}/preview:
827
+ get:
828
+ operationId: previewLogoConceptLockup
829
+ summary: >-
830
+ A free, instant redirect to a preview render of one logo concept in a
831
+ different arrangement and/or font - never persisted, never charged,
832
+ no Idempotency-Key. Query params are both optional; omitting one
833
+ previews with that concept's own current value.
834
+ parameters:
835
+ - { name: id, in: path, required: true, schema: { type: string } }
836
+ - { name: assetVersionId, in: path, required: true, schema: { type: string } }
837
+ - { name: lockup, in: query, schema: { type: string, enum: [horizontal, stacked] } }
838
+ - { name: family, in: query, schema: { type: string } }
839
+ - { name: ink, in: query, schema: { type: string } }
840
+ - { name: accent, in: query, schema: { type: string } }
841
+ - { name: style_seed, in: query, schema: { type: string }, description: "Free-form nonce for the 'shuffle wordmark style' control - a fresh value re-rolls which of a category's 5 treatments this render uses." }
842
+ - { name: redirect, in: query, schema: { type: boolean, default: true } }
843
+ responses:
844
+ "200":
845
+ description: Signed CDN URL as JSON (only when redirect=false)
846
+ content:
847
+ application/json:
848
+ schema:
849
+ type: object
850
+ required: [url]
851
+ properties: { url: { type: string } }
852
+ "302": { description: Redirect to a signed CDN URL for the rendered preview (default) }
853
+ "404": { description: Not found }
854
+ "422": { description: Invalid lockup value }
855
+ "503": { description: The preview render could not be started - try again }
856
+ /jobs/{id}/logo-concepts/{assetVersionId}/palette:
857
+ get:
858
+ operationId: getLogoConceptPalette
859
+ summary: >-
860
+ The brand's generated DNA palette tokens, for the picker's colour
861
+ override swatches - free, read-only.
862
+ parameters:
863
+ - { name: id, in: path, required: true, schema: { type: string } }
864
+ - { name: assetVersionId, in: path, required: true, schema: { type: string } }
865
+ responses:
866
+ "200":
867
+ description: LogoConceptPalette object
868
+ content:
869
+ application/json:
870
+ schema: { $ref: "#/components/schemas/LogoConceptPalette" }
871
+ "404": { description: Not found }
872
+ /jobs/{id}/assets/{kind}/retry:
873
+ post:
874
+ operationId: retryJobAsset
875
+ summary: Retry one failed or credit-blocked asset within an in-progress generation job, for free.
876
+ parameters:
877
+ - { name: id, in: path, required: true, schema: { type: string } }
878
+ - { name: kind, in: path, required: true, schema: { type: string } }
879
+ - { name: Idempotency-Key, in: header, required: true, schema: { type: string } }
880
+ responses:
881
+ "202":
882
+ description: Retry signal accepted.
883
+ headers:
884
+ X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
885
+ X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
886
+ X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
887
+ content:
888
+ application/json:
889
+ schema: { $ref: "#/components/schemas/JobEnvelope" }
890
+ "404": { description: Not found, or the asset kind is not part of this job }
891
+ "409": { description: The asset is not in a retryable state (already generating/ready), or the job has already finalized - `code` distinguishes these }
892
+ "429": { $ref: "#/components/responses/RateLimited" }
893
+ /assets/{id}:
894
+ get:
895
+ operationId: getAsset
896
+ summary: Retrieve a single asset with its current version.
897
+ parameters:
898
+ - { name: id, in: path, required: true, schema: { type: string } }
899
+ responses:
900
+ "200":
901
+ description: Asset object
902
+ headers:
903
+ X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
904
+ X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
905
+ X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
906
+ content:
907
+ application/json:
908
+ schema:
909
+ type: object
910
+ properties:
911
+ asset: { $ref: "#/components/schemas/Asset" }
912
+ "404": { description: Not found }
913
+ "429": { $ref: "#/components/responses/RateLimited" }
914
+ delete:
915
+ operationId: deleteAsset
916
+ summary: Remove an asset from the brand library.
917
+ parameters:
918
+ - { name: id, in: path, required: true, schema: { type: string } }
919
+ responses:
920
+ "204": { description: Deleted }
921
+ "404": { description: Not found }
922
+ "429": { $ref: "#/components/responses/RateLimited" }
923
+ /assets/{id}/qa:
924
+ get:
925
+ operationId: getAssetQaReport
926
+ summary: Get the Pixel Check QA report (per-dimension scores).
927
+ description: >-
928
+ Supported for API consumers regardless of what the Idelio app UI
929
+ currently renders. No in-app screen shows this report today - see
930
+ QaReport's own description for why - but the endpoint and the data
931
+ it returns are a stable, intentional part of the public surface, the
932
+ same status GET /ai-teams holds while its own in-app picker is
933
+ parked.
934
+ parameters:
935
+ - { name: id, in: path, required: true, schema: { type: string } }
936
+ responses:
937
+ "200":
938
+ description: QA report
939
+ headers:
940
+ X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
941
+ X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
942
+ X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
943
+ content:
944
+ application/json:
945
+ schema:
946
+ type: object
947
+ properties:
948
+ report:
949
+ allOf: [{ $ref: "#/components/schemas/QaReport" }]
950
+ nullable: true
951
+ "404": { description: Not found }
952
+ "429": { $ref: "#/components/responses/RateLimited" }
953
+ /assets/{id}/download:
954
+ get:
955
+ operationId: downloadAsset
956
+ summary: Redirect to a time-limited signed CDN URL, or return it as JSON
957
+ with `redirect=false`.
958
+ description: |
959
+ `redirect=false` returns `{ url }` as JSON instead of a 302. A
960
+ browser `fetch()` that then requests that URL directly - rather than
961
+ following our own 302 to it - keeps its real Origin header on the
962
+ request to the CDN: on a same-origin-to-cross-origin 302, browsers
963
+ replace Origin with "null" for the follow-up request, which the
964
+ CDN's CORS policy can never allow-list, so the redirect path fails
965
+ CORS regardless of how the bucket is configured (confirmed live
966
+ 2026-09-17). A plain link or `<img>` navigation is not subject to
967
+ CORS at all and should keep using the default redirect.
968
+ parameters:
969
+ - { name: id, in: path, required: true, schema: { type: string } }
970
+ - name: format
971
+ in: query
972
+ description: >-
973
+ An extension (`png`, `svg`) or a file's full label from the
974
+ version's `files` (`png-favicon-32`). With neither `format` nor
975
+ `size`, the SVG is served when the asset has one.
976
+ schema: { type: string }
977
+ - name: size
978
+ in: query
979
+ description: >-
980
+ Picks a pre-rendered size - favicon 16/32/48/512, app icon
981
+ 64/128/192/512/1024 - and implies `format=png` when `format` is
982
+ omitted. Nothing is resized on request: a size the asset was not
983
+ rendered at answers 415 `unsupported_size`, naming the sizes it has.
984
+ schema: { type: integer, minimum: 1 }
985
+ - { name: redirect, in: query, schema: { type: boolean, default: true } }
986
+ responses:
987
+ "200":
988
+ description: Signed CDN URL as JSON (only when redirect=false)
989
+ content:
990
+ application/json:
991
+ schema:
992
+ type: object
993
+ required: [url]
994
+ properties: { url: { type: string } }
995
+ "302": { description: Redirect to signed CDN URL (default) }
996
+ "404": { description: Not found }
997
+ "415": { description: "The asset has no file in that format (`unsupported_format`) or was not rendered at that size (`unsupported_size`)" }
998
+ "422": { description: "`size` is not a positive whole number" }
999
+ "429": { $ref: "#/components/responses/RateLimited" }
1000
+ /assets/{id}/versions/{versionId}/download:
1001
+ get:
1002
+ operationId: downloadAssetVersion
1003
+ summary: Redirect to a time-limited signed CDN URL for a SPECIFIC version - unlike
1004
+ /assets/{id}/download (always the current version), this previews any version of
1005
+ this asset, selected or not (e.g. a logo concept awaiting the human pick).
1006
+ `redirect=false` returns it as JSON instead - see /assets/{id}/download.
1007
+ parameters:
1008
+ - { name: id, in: path, required: true, schema: { type: string } }
1009
+ - { name: versionId, in: path, required: true, schema: { type: string } }
1010
+ - name: format
1011
+ in: query
1012
+ description: >-
1013
+ An extension (`png`, `svg`) or a file's full label from the
1014
+ version's `files` (`png-favicon-32`). With neither `format` nor
1015
+ `size`, the SVG is served when the asset has one.
1016
+ schema: { type: string }
1017
+ - name: size
1018
+ in: query
1019
+ description: >-
1020
+ Picks a pre-rendered size - favicon 16/32/48/512, app icon
1021
+ 64/128/192/512/1024 - and implies `format=png` when `format` is
1022
+ omitted. Nothing is resized on request: a size the asset was not
1023
+ rendered at answers 415 `unsupported_size`, naming the sizes it has.
1024
+ schema: { type: integer, minimum: 1 }
1025
+ - { name: redirect, in: query, schema: { type: boolean, default: true } }
1026
+ responses:
1027
+ "200":
1028
+ description: Signed CDN URL as JSON (only when redirect=false)
1029
+ content:
1030
+ application/json:
1031
+ schema:
1032
+ type: object
1033
+ required: [url]
1034
+ properties: { url: { type: string } }
1035
+ "302": { description: Redirect to signed CDN URL (default) }
1036
+ "404": { description: Not found }
1037
+ "415": { description: "The asset has no file in that format (`unsupported_format`) or was not rendered at that size (`unsupported_size`)" }
1038
+ "422": { description: "`size` is not a positive whole number" }
1039
+ "429": { $ref: "#/components/responses/RateLimited" }
1040
+ /assets/{id}/download-all:
1041
+ get:
1042
+ operationId: downloadAllAssetFiles
1043
+ summary: Zip every file of the current version together and redirect to a
1044
+ time-limited signed CDN URL for the ZIP - unlike /assets/{id}/download
1045
+ (one file, chosen by `format`), this bundles all of them, one entry
1046
+ per file, so "Download all files" stops silently picking one.
1047
+ `redirect=false` returns it as JSON instead - see /assets/{id}/download.
1048
+ parameters:
1049
+ - { name: id, in: path, required: true, schema: { type: string } }
1050
+ - { name: redirect, in: query, schema: { type: boolean, default: true } }
1051
+ responses:
1052
+ "200":
1053
+ description: Signed CDN URL as JSON (only when redirect=false)
1054
+ content:
1055
+ application/json:
1056
+ schema:
1057
+ type: object
1058
+ required: [url]
1059
+ properties: { url: { type: string } }
1060
+ "302": { description: Redirect to a signed CDN URL for the ZIP (default) }
1061
+ "404": { description: Not found }
1062
+ "429": { $ref: "#/components/responses/RateLimited" }
1063
+ "503": { description: Packaging failed or timed out }
1064
+ /assets/{id}/versions:
1065
+ get:
1066
+ operationId: listAssetVersions
1067
+ summary: List an asset's versions (e.g. logo concepts awaiting selection, or version history).
1068
+ parameters:
1069
+ - { name: id, in: path, required: true, schema: { type: string } }
1070
+ responses:
1071
+ "200":
1072
+ description: Paginated asset versions
1073
+ headers:
1074
+ X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
1075
+ X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
1076
+ X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
1077
+ content:
1078
+ application/json:
1079
+ schema:
1080
+ type: object
1081
+ properties:
1082
+ data:
1083
+ type: array
1084
+ items: { $ref: "#/components/schemas/AssetVersion" }
1085
+ next_cursor: { type: string, nullable: true }
1086
+ has_more: { type: boolean }
1087
+ "404": { description: Not found }
1088
+ "429": { $ref: "#/components/responses/RateLimited" }
1089
+ /assets/{id}/regenerate:
1090
+ post:
1091
+ operationId: regenerateAsset
1092
+ summary: Regenerate an asset's concepts (2 free per asset, then 25% of its base price).
1093
+ parameters:
1094
+ - { name: id, in: path, required: true, schema: { type: string } }
1095
+ - { name: Idempotency-Key, in: header, required: true, schema: { type: string } }
1096
+ responses:
1097
+ "202":
1098
+ description: GenerationJob object (queued)
1099
+ headers:
1100
+ X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
1101
+ X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
1102
+ X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
1103
+ content:
1104
+ application/json:
1105
+ schema: { $ref: "#/components/schemas/JobEnvelope" }
1106
+ "402": { description: Insufficient credits }
1107
+ "404": { description: Not found }
1108
+ "409": { description: This workspace already has its plan's worth of generations running - `code` is `too_many_running_jobs` }
1109
+ "422": { description: "This build cannot price this asset's kind - `code` is `asset_kind_not_priceable`" }
1110
+ "429": { $ref: "#/components/responses/RateLimited" }
1111
+ "501": { description: This asset kind is not regenerable yet }
1112
+ /assets/{id}/refine:
1113
+ post:
1114
+ operationId: refineAsset
1115
+ summary: Refine an asset - a free-text instruction, or font/accent/style overrides for `logo_wordmark`.
1116
+ description: >-
1117
+ For `logo`, sends a free-text instruction. For `logo_wordmark`, sends
1118
+ either a free-text instruction or structured treatment overrides (font
1119
+ family, accent color, style seed) - an instruction is resolved to
1120
+ exactly those three fields, constrained to the font catalog the
1121
+ platform can render and to the brand's own palette, so it never
1122
+ becomes an image prompt. `logo_wordmark` follows the same pricing rule
1123
+ as other assets: first 2 refinements are free, then 25% of the asset's
1124
+ base price per refinement. A `logo_wordmark` instruction that turns
1125
+ out to change the font on a brand whose typography is locked, or that
1126
+ resolves to no change at all, is refused by the job rather than
1127
+ applied in part: the job ends `failed`, its failure event carries the
1128
+ reason, and the reservation is refunded in full.
1129
+ parameters:
1130
+ - { name: id, in: path, required: true, schema: { type: string } }
1131
+ - { name: Idempotency-Key, in: header, required: true, schema: { type: string } }
1132
+ requestBody:
1133
+ required: true
1134
+ content:
1135
+ application/json:
1136
+ schema: { $ref: "#/components/schemas/RefineRequest" }
1137
+ responses:
1138
+ "202":
1139
+ description: GenerationJob object (queued)
1140
+ headers:
1141
+ X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
1142
+ X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
1143
+ X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
1144
+ content:
1145
+ application/json:
1146
+ schema: { $ref: "#/components/schemas/JobEnvelope" }
1147
+ "404": { description: Not found }
1148
+ "409": { description: "This workspace already has its plan's worth of generations running (`code` is `too_many_running_jobs`), or a structured `family` override was sent for a brand whose typography is locked (`code` is `typography_locked`)" }
1149
+ "422": { description: "Invalid instruction, or this build cannot price this asset's kind - `code` is `asset_kind_not_priceable`" }
1150
+ "429": { $ref: "#/components/responses/RateLimited" }
1151
+ "501": { description: This asset kind is not regenerable yet }
1152
+ /assets/{id}/logo-treatment:
1153
+ get:
1154
+ operationId: getLogoTreatment
1155
+ summary: Read a wordmark/logo's current treatment (font, accent, style).
1156
+ tags: [Assets]
1157
+ security: [{ bearerAuth: [] }, { apiKeyAuth: [] }]
1158
+ parameters:
1159
+ - { name: id, in: path, required: true, schema: { type: string }, description: A `logo_wordmark` asset's id. }
1160
+ responses:
1161
+ "200":
1162
+ description: The brand's current logo treatment.
1163
+ content:
1164
+ application/json:
1165
+ schema: { $ref: "#/components/schemas/LogoTreatment" }
1166
+ "404":
1167
+ $ref: "#/components/responses/NotFound"
1168
+ /assets/{id}/logo-treatment/preview:
1169
+ get:
1170
+ operationId: previewLogoTreatment
1171
+ summary: >-
1172
+ A free, instant redirect to a preview render of a wordmark with
1173
+ different font/accent/style - never persisted, never charged, no
1174
+ Idempotency-Key. Query params are optional; omitting one previews with
1175
+ that treatment's current value.
1176
+ tags: [Assets]
1177
+ security: [{ bearerAuth: [] }, { apiKeyAuth: [] }]
1178
+ parameters:
1179
+ - { name: id, in: path, required: true, schema: { type: string }, description: A `logo_wordmark` asset's id. }
1180
+ - { name: family, in: query, schema: { type: string }, description: Font family name. }
1181
+ - { name: ink, in: query, schema: { type: string, pattern: "^#[0-9a-fA-F]{6}$" }, description: Hex name (ink) color. }
1182
+ - { name: accent, in: query, schema: { type: string, pattern: "^#[0-9a-fA-F]{6}$" }, description: Hex accent color. }
1183
+ - { name: style_seed, in: query, schema: { type: string }, description: Free-form nonce for shuffling wordmark style - a fresh value re-rolls which of a category's treatments this render uses. }
1184
+ - { name: style_index, in: query, schema: { type: integer, minimum: 0, maximum: 10 }, description: "Names one of the category's 11 wordmark treatments outright, instead of re-rolling for one. Preferred over style_seed." }
1185
+ - { name: redirect, in: query, schema: { type: boolean, default: true }, description: When true (default), redirect to the preview URL; when false, return the URL as JSON. }
1186
+ responses:
1187
+ "200":
1188
+ description: Signed CDN URL as JSON (only when redirect=false).
1189
+ content:
1190
+ application/json:
1191
+ schema:
1192
+ type: object
1193
+ required: [url]
1194
+ properties:
1195
+ url: { type: string }
1196
+ "302":
1197
+ description: Redirect to a signed CDN URL for the rendered preview (default).
1198
+ "404":
1199
+ $ref: "#/components/responses/NotFound"
1200
+ "422":
1201
+ description: Invalid query parameters.
1202
+ "503":
1203
+ description: The preview render could not be started - try again.
1204
+ /assets/{id}/variations:
1205
+ post:
1206
+ operationId: variationsAsset
1207
+ summary: Generate a curated preset variation of a logo (always logo-kind, counts against the 2-free-regens budget).
1208
+ parameters:
1209
+ - { name: id, in: path, required: true, schema: { type: string } }
1210
+ - { name: Idempotency-Key, in: header, required: true, schema: { type: string } }
1211
+ requestBody:
1212
+ required: true
1213
+ content:
1214
+ application/json:
1215
+ schema: { $ref: "#/components/schemas/VariationRequest" }
1216
+ responses:
1217
+ "202":
1218
+ description: GenerationJob object (queued)
1219
+ headers:
1220
+ X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
1221
+ X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
1222
+ X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
1223
+ content:
1224
+ application/json:
1225
+ schema: { $ref: "#/components/schemas/JobEnvelope" }
1226
+ "402": { description: Insufficient credits }
1227
+ "404": { description: Not found }
1228
+ "409": { description: "This workspace already has its plan's worth of generations running - `code` is `too_many_running_jobs`" }
1229
+ "422": { description: "Unknown preset, the logo has no approved concept yet, or this build cannot price this asset's kind - `code` is `asset_kind_not_priceable`" }
1230
+ "429": { $ref: "#/components/responses/RateLimited" }
1231
+ "501": { description: This asset kind is not regenerable yet }
1232
+ /assets/{id}/restore:
1233
+ post:
1234
+ operationId: restoreAsset
1235
+ summary: Restore a prior asset version as the current version (creates a new version, like git revert - nothing is edited in place).
1236
+ parameters:
1237
+ - { name: id, in: path, required: true, schema: { type: string } }
1238
+ - { name: Idempotency-Key, in: header, required: true, schema: { type: string } }
1239
+ requestBody:
1240
+ required: true
1241
+ content:
1242
+ application/json:
1243
+ schema: { $ref: "#/components/schemas/RestoreRequest" }
1244
+ responses:
1245
+ "201":
1246
+ description: Restored
1247
+ content:
1248
+ application/json:
1249
+ schema:
1250
+ type: object
1251
+ properties:
1252
+ asset: { $ref: "#/components/schemas/Asset" }
1253
+ job:
1254
+ description: Present only when restoring a `logo` version started the free background cascade that re-derives favicon/app_icon/profile_avatar/logo_mono/logo_reversed/logo_alternative from the restored mark. `restore` itself is already complete by the time this response arrives - `job` is purely a handle for a client that wants to know when those derived tiles are caught up too.
1255
+ allOf: [{ $ref: "#/components/schemas/GenerationJob" }]
1256
+ "404": { description: Not found }
1257
+ /credits/balance:
1258
+ get:
1259
+ operationId: getCreditBalance
1260
+ summary: Get the current balance, plan and renewal date.
1261
+ responses:
1262
+ "200":
1263
+ description: Balance summary
1264
+ content:
1265
+ application/json:
1266
+ schema: { $ref: "#/components/schemas/CreditBalance" }
1267
+ /credits/ledger:
1268
+ get:
1269
+ operationId: listCreditLedger
1270
+ summary: List the workspace's append-only credit ledger entries.
1271
+ parameters:
1272
+ - { name: reason, in: query, schema: { type: string, enum: [grant, purchase, reserve, settle, refund, clawback] } }
1273
+ - { name: cursor, in: query, schema: { type: string } }
1274
+ - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } }
1275
+ responses:
1276
+ "200":
1277
+ description: Ledger page, newest first
1278
+ content:
1279
+ application/json:
1280
+ schema: { $ref: "#/components/schemas/LedgerPage" }
1281
+ /ai-teams:
1282
+ get:
1283
+ operationId: listAiTeams
1284
+ summary: List the AI team tiers and their credit multipliers.
1285
+ responses:
1286
+ "200":
1287
+ description: AI team tiers
1288
+ content:
1289
+ application/json:
1290
+ schema:
1291
+ type: object
1292
+ properties:
1293
+ data:
1294
+ type: array
1295
+ items: { $ref: "#/components/schemas/AiTeam" }
1296
+ /healthz:
1297
+ get:
1298
+ operationId: healthz
1299
+ summary: Liveness and dependency health probe (unversioned in prod routing).
1300
+ security: []
1301
+ responses:
1302
+ "200": { description: Healthy }
1303
+ "503": { description: Degraded }
1304
+ /waitlist:
1305
+ post:
1306
+ operationId: joinWaitlist
1307
+ summary: Join the public waitlist. No authentication required.
1308
+ security: []
1309
+ requestBody:
1310
+ required: true
1311
+ content:
1312
+ application/json:
1313
+ schema: { $ref: "#/components/schemas/WaitlistSignupRequest" }
1314
+ responses:
1315
+ "200":
1316
+ description: >-
1317
+ Joined (or already on the list - the response is identical
1318
+ either way).
1319
+ content:
1320
+ application/json:
1321
+ schema: { $ref: "#/components/schemas/WaitlistSignupResponse" }
1322
+ "422":
1323
+ description: >-
1324
+ Validation failed - including when marketing_consent is true but
1325
+ privacy_policy_version is missing.
1326
+ "429": { description: Rate limit exceeded }
1327
+ /consent:
1328
+ post:
1329
+ operationId: recordConsent
1330
+ summary: >-
1331
+ Record proof of cookie/privacy/Terms consent, or of what a checkout
1332
+ confirmation step disclosed. Cookie and privacy records need no
1333
+ authentication (client_id is the caller's anonymous/session
1334
+ identifier), and a purchase disclosure is sent with a session so it
1335
+ attributes to the workspace. A terms_acceptance REQUIRES a session and
1336
+ is refused with 401 without one: a record scoped to no workspace could
1337
+ never be read back, so an anonymous one would evidence nothing.
1338
+ security: []
1339
+ requestBody:
1340
+ required: true
1341
+ content:
1342
+ application/json:
1343
+ schema: { $ref: "#/components/schemas/ConsentRequest" }
1344
+ responses:
1345
+ "201":
1346
+ description: Consent recorded.
1347
+ content:
1348
+ application/json:
1349
+ schema: { $ref: "#/components/schemas/ConsentResponse" }
1350
+ "400":
1351
+ description: Validation failed.
1352
+ content:
1353
+ application/json:
1354
+ schema: { $ref: "#/components/schemas/ErrorEnvelope" }
1355
+ "401":
1356
+ description: >-
1357
+ A terms_acceptance was sent without a verifiable session. That type
1358
+ alone is never recorded anonymously - an anonymous row is scoped to
1359
+ no workspace, so nothing can ever read it back.
1360
+ content:
1361
+ application/json:
1362
+ schema: { $ref: "#/components/schemas/ErrorEnvelope" }
1363
+ "422":
1364
+ description: >-
1365
+ A terms_acceptance whose checkbox_text or accepted_versions are not
1366
+ the canonical values the sign-up screen shows.
1367
+ content:
1368
+ application/json:
1369
+ schema: { $ref: "#/components/schemas/ErrorEnvelope" }
1370
+ "429": { $ref: "#/components/responses/RateLimited" }
1371
+ /api-keys:
1372
+ get:
1373
+ operationId: listApiKeys
1374
+ summary: List the workspace's API keys (non-revoked only).
1375
+ responses:
1376
+ "200":
1377
+ description: API keys
1378
+ headers:
1379
+ X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
1380
+ X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
1381
+ X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
1382
+ content:
1383
+ application/json:
1384
+ schema:
1385
+ type: object
1386
+ properties:
1387
+ data:
1388
+ type: array
1389
+ items: { $ref: "#/components/schemas/ApiKey" }
1390
+ "429": { $ref: "#/components/responses/RateLimited" }
1391
+ post:
1392
+ operationId: createApiKey
1393
+ description: Available to a signed-in app session with at least the admin role. An API key receives 403 `session_required`.
1394
+ security: [{ bearerAuth: [] }]
1395
+ summary: Mint a new API key. The secret is shown only in this response.
1396
+ requestBody:
1397
+ required: true
1398
+ content:
1399
+ application/json:
1400
+ schema: { $ref: "#/components/schemas/CreateApiKeyRequest" }
1401
+ responses:
1402
+ "201":
1403
+ description: The new key, including its one-time secret
1404
+ headers:
1405
+ X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
1406
+ X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
1407
+ X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
1408
+ content:
1409
+ application/json:
1410
+ schema: { $ref: "#/components/schemas/ApiKeyWithSecret" }
1411
+ "403": { description: Caller's role is below admin }
1412
+ "422": { description: Invalid request body }
1413
+ "429": { $ref: "#/components/responses/RateLimited" }
1414
+ /api-keys/{id}:
1415
+ delete:
1416
+ operationId: revokeApiKey
1417
+ description: Available to a signed-in app session with at least the admin role. An API key receives 403 `session_required`.
1418
+ security: [{ bearerAuth: [] }]
1419
+ summary: Revoke an API key immediately.
1420
+ parameters:
1421
+ - { name: id, in: path, required: true, schema: { type: string } }
1422
+ responses:
1423
+ "204": { description: Revoked }
1424
+ "403": { description: Caller's role is below admin }
1425
+ "404": { description: No active key with that id }
1426
+ "429": { $ref: "#/components/responses/RateLimited" }
1427
+ /usage:
1428
+ get:
1429
+ operationId: getUsage
1430
+ summary: Aggregate API-key usage for the workspace, bucketed by time.
1431
+ parameters:
1432
+ - { name: from, in: query, schema: { type: string, format: date-time } }
1433
+ - { name: to, in: query, schema: { type: string, format: date-time } }
1434
+ - { name: granularity, in: query, schema: { type: string, enum: [day, week, month], default: day } }
1435
+ responses:
1436
+ "200":
1437
+ description: Usage buckets
1438
+ content:
1439
+ application/json:
1440
+ schema: { $ref: "#/components/schemas/UsagePage" }
1441
+ /webhook-endpoints:
1442
+ get:
1443
+ operationId: listWebhookEndpoints
1444
+ summary: List the workspace's outbound webhook endpoints (secrets never included).
1445
+ responses:
1446
+ "200":
1447
+ description: Webhook endpoints
1448
+ headers:
1449
+ X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
1450
+ X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
1451
+ X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
1452
+ content:
1453
+ application/json:
1454
+ schema:
1455
+ type: object
1456
+ properties:
1457
+ data:
1458
+ type: array
1459
+ items: { $ref: "#/components/schemas/WebhookEndpoint" }
1460
+ "429": { $ref: "#/components/responses/RateLimited" }
1461
+ post:
1462
+ operationId: createWebhookEndpoint
1463
+ description: A session needs at least the admin role; an API key needs the `webhooks:write` scope.
1464
+ summary: Register a new outbound webhook endpoint. The signing secret is shown only in this response.
1465
+ requestBody:
1466
+ required: true
1467
+ content:
1468
+ application/json:
1469
+ schema: { $ref: "#/components/schemas/CreateWebhookEndpointRequest" }
1470
+ responses:
1471
+ "201":
1472
+ description: The new endpoint, including its one-time signing secret
1473
+ headers:
1474
+ X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
1475
+ X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
1476
+ X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
1477
+ content:
1478
+ application/json:
1479
+ schema: { $ref: "#/components/schemas/WebhookEndpointWithSecret" }
1480
+ "403": { description: Caller's role is below admin }
1481
+ "422": { description: Invalid request body }
1482
+ "429": { $ref: "#/components/responses/RateLimited" }
1483
+ /webhook-endpoints/{id}:
1484
+ patch:
1485
+ operationId: updateWebhookEndpoint
1486
+ description: A session needs at least the admin role; an API key needs the `webhooks:write` scope.
1487
+ summary: Update an endpoint's subscribed events, or enable/disable it.
1488
+ parameters:
1489
+ - { name: id, in: path, required: true, schema: { type: string } }
1490
+ requestBody:
1491
+ required: true
1492
+ content:
1493
+ application/json:
1494
+ schema: { $ref: "#/components/schemas/UpdateWebhookEndpointRequest" }
1495
+ responses:
1496
+ "200":
1497
+ description: The updated endpoint
1498
+ headers:
1499
+ X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
1500
+ X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
1501
+ X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
1502
+ content:
1503
+ application/json:
1504
+ schema:
1505
+ type: object
1506
+ properties:
1507
+ endpoint: { $ref: "#/components/schemas/WebhookEndpoint" }
1508
+ "403": { description: Caller's role is below admin }
1509
+ "404": { description: No webhook endpoint with that id }
1510
+ "422": { description: Invalid request body }
1511
+ "429": { $ref: "#/components/responses/RateLimited" }
1512
+ delete:
1513
+ operationId: deleteWebhookEndpoint
1514
+ description: >-
1515
+ A session needs at least the admin role; an API key needs the `webhooks:write` scope.
1516
+ The endpoint stops receiving events at once, including retries of a delivery already in
1517
+ flight, and every later call naming it answers 404.
1518
+ summary: Remove a webhook endpoint.
1519
+ parameters:
1520
+ - { name: id, in: path, required: true, schema: { type: string } }
1521
+ responses:
1522
+ "204": { description: Removed }
1523
+ "403": { description: Caller's role is below admin }
1524
+ "404": { description: No webhook endpoint with that id }
1525
+ "429": { $ref: "#/components/responses/RateLimited" }
1526
+ /webhook-endpoints/{id}/test:
1527
+ post:
1528
+ operationId: testWebhookEndpoint
1529
+ description: A session needs at least the admin role; an API key needs the `webhooks:write` scope.
1530
+ summary: Dispatch a synthetic test.ping event to the endpoint.
1531
+ parameters:
1532
+ - { name: id, in: path, required: true, schema: { type: string } }
1533
+ responses:
1534
+ "202":
1535
+ description: Delivery dispatched
1536
+ headers:
1537
+ X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
1538
+ X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
1539
+ X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
1540
+ content:
1541
+ application/json:
1542
+ schema:
1543
+ type: object
1544
+ properties:
1545
+ dispatched: { type: boolean }
1546
+ "403": { description: Caller's role is below admin }
1547
+ "404": { description: No webhook endpoint with that id }
1548
+ "429": { $ref: "#/components/responses/RateLimited" }
1549
+ /webhooks/stripe:
1550
+ post:
1551
+ operationId: receiveStripeWebhook
1552
+ summary: >-
1553
+ Inbound Stripe event (checkout/invoice/subscription). HMAC-SHA256
1554
+ signed via the Stripe-Signature header - not the same auth as the rest
1555
+ of the API. Drives the credit ledger and workspace plan/entitlements.
1556
+ Stripe is not Merchant of Record: SFER LABS LLC is the seller, so tax
1557
+ and invoicing responsibility sits with us.
1558
+ security: []
1559
+ requestBody:
1560
+ required: true
1561
+ content:
1562
+ application/json:
1563
+ schema: { $ref: "#/components/schemas/StripeWebhookEvent" }
1564
+ responses:
1565
+ "200":
1566
+ description: Received (and processed, or a no-op replay of an already-seen event id)
1567
+ content:
1568
+ application/json:
1569
+ schema:
1570
+ type: object
1571
+ properties:
1572
+ received: { type: boolean }
1573
+ "401":
1574
+ description: Signature verification failed
1575
+ content:
1576
+ application/json:
1577
+ schema: { $ref: "#/components/schemas/ErrorEnvelope" }
1578
+ "422": { description: Payload failed validation (after signature verification) }
1579
+ /webhooks/clerk:
1580
+ post:
1581
+ operationId: receiveClerkWebhook
1582
+ summary: >-
1583
+ Inbound Clerk lifecycle event (organization.deleted,
1584
+ organizationMembership.deleted, ...). Verified via svix
1585
+ (svix-id/svix-timestamp/svix-signature headers) - not the same auth
1586
+ as the rest of the API.
1587
+ security: []
1588
+ requestBody:
1589
+ required: true
1590
+ content:
1591
+ application/json:
1592
+ schema: { type: object, description: "Clerk's own event envelope (opaque - not tightly typed here)." }
1593
+ responses:
1594
+ "200":
1595
+ description: Received (and processed, or a no-op replay of an already-seen event id)
1596
+ content:
1597
+ application/json:
1598
+ schema:
1599
+ type: object
1600
+ properties:
1601
+ received: { type: boolean }
1602
+ "401":
1603
+ description: Signature verification failed
1604
+ content:
1605
+ application/json:
1606
+ schema: { $ref: "#/components/schemas/ErrorEnvelope" }
1607
+ /billing/subscription:
1608
+ get:
1609
+ operationId: getSubscription
1610
+ description: Available to a signed-in app session with at least the admin role. An API key receives 403 `session_required`.
1611
+ security: [{ bearerAuth: [] }]
1612
+ summary: Get the active subscription and entitlements.
1613
+ responses:
1614
+ "200":
1615
+ description: Subscription object
1616
+ headers:
1617
+ X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
1618
+ X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
1619
+ X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
1620
+ content:
1621
+ application/json:
1622
+ schema:
1623
+ type: object
1624
+ properties:
1625
+ subscription:
1626
+ oneOf:
1627
+ - { $ref: "#/components/schemas/Subscription" }
1628
+ - { type: "null" }
1629
+ "429": { $ref: "#/components/responses/RateLimited" }
1630
+ /billing/checkout:
1631
+ post:
1632
+ operationId: createCheckout
1633
+ description: Available to a signed-in app session with at least the admin role. An API key receives 403 `session_required`.
1634
+ security: [{ bearerAuth: [] }]
1635
+ summary: Create a checkout session for a plan or credit pack.
1636
+ parameters:
1637
+ - name: Idempotency-Key
1638
+ in: header
1639
+ required: true
1640
+ schema: { type: string }
1641
+ requestBody:
1642
+ required: true
1643
+ content:
1644
+ application/json:
1645
+ schema: { $ref: "#/components/schemas/CheckoutRequest" }
1646
+ responses:
1647
+ "201":
1648
+ description: Checkout session
1649
+ headers:
1650
+ X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
1651
+ X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
1652
+ X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
1653
+ content:
1654
+ application/json:
1655
+ schema:
1656
+ type: object
1657
+ properties:
1658
+ checkout:
1659
+ type: object
1660
+ properties:
1661
+ url: { type: string }
1662
+ "422":
1663
+ description: >
1664
+ Unrecognized price_id, or a success_url whose origin is not the
1665
+ app's own (APP_BASE_URL).
1666
+ "429": { $ref: "#/components/responses/RateLimited" }
1667
+ /billing/checkout/{id}:
1668
+ get:
1669
+ operationId: getCheckoutSession
1670
+ security: [{ bearerAuth: [] }]
1671
+ summary: Read whether a checkout has been applied to this workspace yet.
1672
+ description: >
1673
+ Available to a signed-in app session with at least the admin role; an
1674
+ API key receives 403 `session_required`.
1675
+ Reports settlement in Idelio, not at the payment provider. A plan
1676
+ purchase is only `paid` once both the credit grant and the plan change
1677
+ have landed, because they arrive on separate provider webhooks in an
1678
+ order the provider does not guarantee. Answers 404 - never 403 - for
1679
+ an id belonging to another workspace, so the endpoint cannot be used
1680
+ to confirm that an id exists.
1681
+ parameters:
1682
+ - { name: id, in: path, required: true, schema: { type: string } }
1683
+ responses:
1684
+ "200":
1685
+ description: Checkout session
1686
+ headers:
1687
+ X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
1688
+ X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
1689
+ X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
1690
+ content:
1691
+ application/json:
1692
+ schema:
1693
+ type: object
1694
+ properties:
1695
+ checkout: { $ref: "#/components/schemas/CheckoutSession" }
1696
+ "404": { description: No such checkout session for this workspace }
1697
+ "429": { $ref: "#/components/responses/RateLimited" }
1698
+ /billing/subscription/change-plan:
1699
+ post:
1700
+ operationId: changeSubscriptionPlan
1701
+ description: Available to a signed-in app session with at least the admin role. An API key receives 403 `session_required`.
1702
+ security: [{ bearerAuth: [] }]
1703
+ summary: >-
1704
+ Switch an existing subscription to a different plan. Upgrades apply
1705
+ immediately (prorated credits granted right away); downgrades are
1706
+ queued for the end of the current billing period and reported back
1707
+ via pending_plan/plan_change_effective_at.
1708
+ parameters:
1709
+ - name: Idempotency-Key
1710
+ in: header
1711
+ required: true
1712
+ schema: { type: string }
1713
+ requestBody:
1714
+ required: true
1715
+ content:
1716
+ application/json:
1717
+ schema: { $ref: "#/components/schemas/ChangePlanRequest" }
1718
+ responses:
1719
+ "200":
1720
+ description: The subscription after applying (or queuing) the change
1721
+ headers:
1722
+ X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
1723
+ X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
1724
+ X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
1725
+ content:
1726
+ application/json:
1727
+ schema:
1728
+ type: object
1729
+ properties:
1730
+ subscription: { $ref: "#/components/schemas/Subscription" }
1731
+ "409": { description: No active subscription to change - use POST /billing/checkout instead }
1732
+ "422": { description: Unrecognized price_id, a top-up price, or the workspace's current plan }
1733
+ "429": { $ref: "#/components/responses/RateLimited" }
1734
+ /billing/catalog:
1735
+ get:
1736
+ operationId: getBillingCatalog
1737
+ description: Available to a signed-in app session with at least the admin role. An API key receives 403 `session_required`.
1738
+ security: [{ bearerAuth: [] }]
1739
+ summary: Resolve the active provider's price_id for every plan and top-up pack.
1740
+ responses:
1741
+ "200":
1742
+ description: Provider-agnostic price catalog
1743
+ headers:
1744
+ X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
1745
+ X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
1746
+ X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
1747
+ content:
1748
+ application/json:
1749
+ schema: { $ref: "#/components/schemas/BillingCatalog" }
1750
+ "429": { $ref: "#/components/responses/RateLimited" }
1751
+ /billing/portal:
1752
+ post:
1753
+ operationId: createPortalSession
1754
+ description: Available to a signed-in app session with at least the admin role. An API key receives 403 `session_required`.
1755
+ security: [{ bearerAuth: [] }]
1756
+ summary: Create a Stripe customer-portal session.
1757
+ parameters:
1758
+ - name: Idempotency-Key
1759
+ in: header
1760
+ required: true
1761
+ schema: { type: string }
1762
+ responses:
1763
+ "201":
1764
+ description: Portal session URL
1765
+ headers:
1766
+ X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
1767
+ X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
1768
+ X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
1769
+ content:
1770
+ application/json:
1771
+ schema:
1772
+ type: object
1773
+ properties:
1774
+ portal:
1775
+ type: object
1776
+ properties:
1777
+ url: { type: string }
1778
+ "409": { description: No billing customer for this workspace yet }
1779
+ "429": { $ref: "#/components/responses/RateLimited" }
1780
+ /billing/payment-method:
1781
+ get:
1782
+ operationId: getPaymentMethod
1783
+ security: [{ bearerAuth: [] }]
1784
+ summary: The card this workspace's charges land on.
1785
+ description: >
1786
+ Available to a signed-in app session with at least the admin role. An API key receives 403 `session_required`.
1787
+ Returns null when there is no card on file - which is the normal state for a workspace
1788
+ that has never paid, and for a saved method that is not a card (PayPal, a bank debit).
1789
+ Null is an answer rather than a failure: render the absence. Never the full number and
1790
+ never a token; this exists so a user can tell WHICH of their cards is on file. Changing
1791
+ it happens in the provider's own portal.
1792
+ responses:
1793
+ "200":
1794
+ description: The card on file, or null
1795
+ headers:
1796
+ X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
1797
+ X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
1798
+ X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
1799
+ content:
1800
+ application/json:
1801
+ schema:
1802
+ type: object
1803
+ properties:
1804
+ payment_method:
1805
+ nullable: true
1806
+ allOf: [{ $ref: "#/components/schemas/PaymentMethod" }]
1807
+ "429": { $ref: "#/components/responses/RateLimited" }
1808
+ /billing/invoices:
1809
+ get:
1810
+ operationId: listInvoices
1811
+ security: [{ bearerAuth: [] }]
1812
+ summary: List the workspace's invoices from Stripe.
1813
+ description: >
1814
+ Available to a signed-in app session with at least the admin role. An API key receives 403 `session_required`.
1815
+ Results are newest first. That order is inherited rather than pinned: Stripe's invoice
1816
+ list exposes no ordering parameter and orders by creation date, which is already newest
1817
+ first. Note that `billed_at` is the date the invoice was issued, which is not the key
1818
+ the list is ordered by: an invoice finalized long after it was created carries a
1819
+ `billed_at` later than its position in the list implies. Order by `billed_at`
1820
+ client-side if you need the two to agree.
1821
+ parameters:
1822
+ - { name: cursor, in: query, schema: { type: string } }
1823
+ - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } }
1824
+ responses:
1825
+ "200":
1826
+ description: Paginated invoices
1827
+ headers:
1828
+ X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
1829
+ X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
1830
+ X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
1831
+ content:
1832
+ application/json:
1833
+ schema:
1834
+ type: object
1835
+ properties:
1836
+ data:
1837
+ type: array
1838
+ items: { $ref: "#/components/schemas/Invoice" }
1839
+ next_cursor: { type: string, nullable: true }
1840
+ has_more: { type: boolean }
1841
+ "429": { $ref: "#/components/responses/RateLimited" }
1842
+ /billing/auto-topup:
1843
+ get:
1844
+ operationId: getAutoTopup
1845
+ security: [{ bearerAuth: [] }]
1846
+ summary: Get the workspace's auto top-up preference.
1847
+ description: >-
1848
+ Available to a signed-in app session with at least the admin role. An API key receives 403 `session_required`.
1849
+ The stored preference only. Nothing acts on it yet: no automatic
1850
+ purchase is made when the balance runs low, for any workspace (#426).
1851
+ responses:
1852
+ "200":
1853
+ description: Auto top-up preference
1854
+ headers:
1855
+ X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
1856
+ X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
1857
+ X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
1858
+ content:
1859
+ application/json:
1860
+ schema:
1861
+ type: object
1862
+ properties:
1863
+ auto_topup: { $ref: "#/components/schemas/AutoTopup" }
1864
+ "429": { $ref: "#/components/responses/RateLimited" }
1865
+ patch:
1866
+ operationId: updateAutoTopup
1867
+ security: [{ bearerAuth: [] }]
1868
+ summary: Store the workspace's auto top-up preference.
1869
+ description: >-
1870
+ Available to a signed-in app session with at least the admin role. An API key receives 403 `session_required`.
1871
+ Records the preference and the pack it names. It does NOT arrange a
1872
+ purchase: nothing reads these values, and no credits are ever bought
1873
+ automatically (#426). Do not build against an automatic top-up until
1874
+ that issue closes.
1875
+ requestBody:
1876
+ required: true
1877
+ content:
1878
+ application/json:
1879
+ schema: { $ref: "#/components/schemas/UpdateAutoTopupRequest" }
1880
+ responses:
1881
+ "200":
1882
+ description: Updated auto top-up preference
1883
+ headers:
1884
+ X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
1885
+ X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
1886
+ X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
1887
+ content:
1888
+ application/json:
1889
+ schema:
1890
+ type: object
1891
+ properties:
1892
+ auto_topup: { $ref: "#/components/schemas/AutoTopup" }
1893
+ "422": { description: "pack_credits missing while enabling, or not one of 100/300/1000" }
1894
+ "429": { $ref: "#/components/responses/RateLimited" }
1895
+ /templates:
1896
+ get:
1897
+ operationId: listTemplates
1898
+ summary: List available brand templates.
1899
+ parameters:
1900
+ - { name: category, in: query, schema: { type: string } }
1901
+ - { name: cursor, in: query, schema: { type: string } }
1902
+ responses:
1903
+ "200":
1904
+ description: Paginated templates
1905
+ headers:
1906
+ X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
1907
+ X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
1908
+ X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
1909
+ content:
1910
+ application/json:
1911
+ schema:
1912
+ type: object
1913
+ properties:
1914
+ data:
1915
+ type: array
1916
+ items: { $ref: "#/components/schemas/Template" }
1917
+ next_cursor: { type: string, nullable: true }
1918
+ has_more: { type: boolean }
1919
+ "429": { $ref: "#/components/responses/RateLimited" }
1920
+ /templates/{id}:
1921
+ get:
1922
+ operationId: getTemplate
1923
+ summary: Retrieve a template definition.
1924
+ parameters:
1925
+ - { name: id, in: path, required: true, schema: { type: string } }
1926
+ responses:
1927
+ "200":
1928
+ description: Template object
1929
+ headers:
1930
+ X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
1931
+ X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
1932
+ X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
1933
+ content:
1934
+ application/json:
1935
+ schema:
1936
+ type: object
1937
+ properties:
1938
+ template: { $ref: "#/components/schemas/Template" }
1939
+ "404": { description: Not found }
1940
+ "429": { $ref: "#/components/responses/RateLimited" }
1941
+ components:
1942
+ securitySchemes:
1943
+ bearerAuth:
1944
+ type: http
1945
+ scheme: bearer
1946
+ apiKeyAuth:
1947
+ type: http
1948
+ scheme: bearer
1949
+ description: >
1950
+ Machine credential `sk_live_...` / `sk_test_...` sent as the Bearer
1951
+ token; scoped per key. A key acts for the workspace, not a person, so it
1952
+ holds no workspace role: operations that need one (API keys, billing,
1953
+ members, workspace settings) are session-only and answer a key with 403
1954
+ `session_required`. Webhook endpoints are the exception - a key manages
1955
+ them with the `webhooks:write` scope.
1956
+ # Per-API-key token-bucket rate limiting. Every route reachable via an API
1957
+ # key returns these three headers on success and a 429 (see the
1958
+ # RateLimited response below) once the caller's bucket is empty.
1959
+ # OpenAPI has no single $ref for a group of headers - reference all three
1960
+ # individually wherever RateLimitLimit is referenced. Applied here to the
1961
+ # api-keys, webhook-endpoints, brands, assets and jobs resource groups (the
1962
+ # ones a machine client actually calls) as a representative, meaningfully
1963
+ # -scoped set; apply to the remaining paths in a follow-up once the
1964
+ # limiter's uniform coverage across all authenticated routes is confirmed
1965
+ # in production.
1966
+ headers:
1967
+ RateLimitLimit:
1968
+ description: Sustained requests-per-minute allowed for the caller's plan.
1969
+ schema: { type: integer }
1970
+ RateLimitRemaining:
1971
+ description: Tokens left in the caller's bucket right now.
1972
+ schema: { type: integer }
1973
+ RateLimitReset:
1974
+ description: Seconds until the bucket is fully refilled back to its burst ceiling.
1975
+ schema: { type: integer }
1976
+ responses:
1977
+ RateLimited:
1978
+ description: Rate limit exceeded - the caller's token bucket is empty.
1979
+ content:
1980
+ application/json:
1981
+ schema: { $ref: "#/components/schemas/ErrorEnvelope" }
1982
+ example:
1983
+ error:
1984
+ type: rate_limit_error
1985
+ code: rate_limited
1986
+ message: Rate limit exceeded. Retry after the interval in Retry-After.
1987
+ request_id: req_01hz3x9k2q
1988
+ docs_url: https://docs.idelio.pro/errors/rate_limited
1989
+ schemas:
1990
+ CreditBalance:
1991
+ type: object
1992
+ properties:
1993
+ balance: { type: integer }
1994
+ plan: { type: string, enum: [free, pro, studio, agency] }
1995
+ monthly_allowance: { type: integer }
1996
+ renews_at: { type: string, format: date-time, nullable: true }
1997
+ LedgerEntry:
1998
+ type: object
1999
+ properties:
2000
+ id: { type: string }
2001
+ delta: { type: integer }
2002
+ reason: { type: string, enum: [grant, purchase, reserve, settle, refund, clawback] }
2003
+ job_id: { type: string, nullable: true }
2004
+ balance_after: { type: integer }
2005
+ created_at: { type: string, format: date-time }
2006
+ LedgerPage:
2007
+ type: object
2008
+ properties:
2009
+ data:
2010
+ type: array
2011
+ items: { $ref: "#/components/schemas/LedgerEntry" }
2012
+ next_cursor: { type: string, nullable: true }
2013
+ has_more: { type: boolean }
2014
+ CreateBrandRequest:
2015
+ type: object
2016
+ properties:
2017
+ prompt: { type: string, minLength: 8 }
2018
+ template_id: { type: string }
2019
+ name: { type: string, minLength: 1, maxLength: 80 }
2020
+ tagline: { type: string, minLength: 1, maxLength: 60, description: What the user typed in the creator - pinned in code rather than suggested to the model }
2021
+ assets:
2022
+ type: array
2023
+ description: >-
2024
+ The asset kinds to generate. Every brand includes five kinds -
2025
+ "logo" (the symbol), "logo_alternative" (the logo lockup),
2026
+ "logo_wordmark", "color_palette" and "font_pairing" - 7 credits
2027
+ at Studio together. Omit the list, or send an empty array, to
2028
+ generate exactly those five. A non-empty list MUST contain all
2029
+ five, and anything else is added on top; a list missing any of
2030
+ them answers 422 `required_assets_missing`. More kinds can be
2031
+ added to an existing brand later with POST /brands/{id}/assets.
2032
+ items: { type: string }
2033
+ team: { type: string, enum: [flash, studio, elite], default: studio }
2034
+ customize:
2035
+ type: object
2036
+ deprecated: true
2037
+ description: >-
2038
+ Superseded by creative_direction, which is typed against the
2039
+ published catalogs instead of accepting free-form records. Still
2040
+ accepted for released SDK versions. Neither block reaches the
2041
+ generation pipeline yet - both are accepted and validated only -
2042
+ so new clients should send creative_direction and stop sending
2043
+ this one.
2044
+ creative_direction: { $ref: "#/components/schemas/CreativeDirection" }
2045
+ auto_select_logo:
2046
+ type: boolean
2047
+ default: false
2048
+ description: >
2049
+ Every generation pauses once its logo concepts exist and waits for
2050
+ one to be picked through `POST /jobs/{id}/logo-selection` - for up
2051
+ to 24 hours, after which the job fails and its credits are
2052
+ refunded. `true` picks the first concept on the server instead, so
2053
+ a caller with nobody to choose can poll the job straight to
2054
+ `completed`.
2055
+ CreativeDirection:
2056
+ type: object
2057
+ additionalProperties: false
2058
+ description: >-
2059
+ The creative direction the user chose. Every axis accepts the literal
2060
+ "ai", which means "let the pipeline decide" - an explicit value, not
2061
+ an omitted field. Ids come from the published catalogs in
2062
+ @idelio/contracts; an id outside them is rejected.
2063
+ properties:
2064
+ styles:
2065
+ type: array
2066
+ maxItems: 5
2067
+ items: { type: string, enum: [modern, minimal, bold, playful, elegant, corporate, luxury, organic, geometric, handcrafted, brutalist, editorial, retro, vintage, futuristic, timeless, art_deco, y2k, calm, energetic, warm, technical, rebellious, premium] }
2068
+ color:
2069
+ oneOf:
2070
+ - type: object
2071
+ additionalProperties: false
2072
+ required: [mode]
2073
+ properties:
2074
+ mode: { type: string, enum: [ai] }
2075
+ - type: object
2076
+ additionalProperties: false
2077
+ required: [mode, preset_id]
2078
+ properties:
2079
+ mode: { type: string, enum: [preset] }
2080
+ preset_id: { type: string, enum: [indigo, teal, amber, forest, rose, slate, midnight, coral, sand, plum, ocean, citrus, mono_warm, berry] }
2081
+ - type: object
2082
+ additionalProperties: false
2083
+ required: [mode, colors]
2084
+ properties:
2085
+ mode: { type: string, enum: [custom] }
2086
+ colors:
2087
+ type: array
2088
+ minItems: 3
2089
+ maxItems: 6
2090
+ items: { type: string, pattern: "^#[0-9a-fA-F]{6}$" }
2091
+ typography:
2092
+ oneOf:
2093
+ - type: object
2094
+ additionalProperties: false
2095
+ required: [mode]
2096
+ properties:
2097
+ mode: { type: string, enum: [ai] }
2098
+ - type: object
2099
+ additionalProperties: false
2100
+ required: [mode, preset_id]
2101
+ properties:
2102
+ mode: { type: string, enum: [preset] }
2103
+ preset_id: { type: string, enum: [sora_inter, fraunces_inter, space_inter, playfair_source, dmserif_dmsans, outfit_inter, manrope_manrope, bricolage_inter, instrument_inter, archivo_archivo, lora_lato, syne_inter, epilogue_inter, ibmplex_ibmplex, jost_inter, newsreader_inter, chivo_chivo, unbounded_inter, figtree_figtree, spectral_inter] }
2104
+ logo:
2105
+ type: object
2106
+ additionalProperties: false
2107
+ description: >-
2108
+ Two independent axes. A mark style and a lockup are not
2109
+ alternatives - a brand has one of each. The single impossible
2110
+ pair is rejected: style "wordmark_only" with lockup "icon_only"
2111
+ describes a logo with neither icon nor name.
2112
+ properties:
2113
+ style: { type: string, enum: [ai, geometric, rounded, circular, sharp, monogram, lettermark, abstract, emblem, organic, line, gradient, wordmark_only], default: ai }
2114
+ lockup: { type: string, enum: [ai, horizontal, stacked, icon_only], default: ai }
2115
+ UpdateBrandRequest:
2116
+ type: object
2117
+ properties:
2118
+ name: { type: string, minLength: 1 }
2119
+ slug: { type: string, minLength: 1 }
2120
+ locks: { type: object }
2121
+ ExpandBrandRequest:
2122
+ type: object
2123
+ required: [assets]
2124
+ properties:
2125
+ assets:
2126
+ type: array
2127
+ minItems: 1
2128
+ items: { type: string }
2129
+ team:
2130
+ type: string
2131
+ enum: [flash, studio, elite]
2132
+ custom_image_prompt:
2133
+ type: string
2134
+ minLength: 1
2135
+ maxLength: 500
2136
+ description: Required when assets is exactly ["custom_image"].
2137
+ custom_image_format:
2138
+ type: string
2139
+ enum: [square_1_1, portrait_4_5, story_9_16, landscape_16_9, wide_21_9, photo_3_2, poster_2_3, standard_4_3, auto, instagram_post, story, linkedin_post, ad_banner, custom]
2140
+ custom_image_name:
2141
+ type: string
2142
+ minLength: 1
2143
+ maxLength: 60
2144
+ description: >-
2145
+ Library name for the generated custom asset. Optional - when absent
2146
+ the name is derived from custom_image_prompt. Only valid when assets
2147
+ is exactly ["custom_image"]. Leading and trailing whitespace is
2148
+ trimmed BEFORE minLength/maxLength are applied, so a 64-character
2149
+ value whose padding trims to 58 is accepted, and a value of only
2150
+ spaces is rejected as empty. Control characters (C0 and DEL) are
2151
+ refused.
2152
+ BrandRefineRequest:
2153
+ type: object
2154
+ required: [message]
2155
+ properties:
2156
+ message: { type: string, minLength: 1, maxLength: 500 }
2157
+ QaCheck:
2158
+ type: object
2159
+ description: >-
2160
+ One criterion the Brand Guardian evaluated. `label` travels with the
2161
+ check because the set of criteria is the Guardian's to decide - a client
2162
+ owning the labels would print nothing for a check it had not heard of.
2163
+ required: [id, label, status]
2164
+ properties:
2165
+ id: { type: string }
2166
+ label: { type: string }
2167
+ status: { type: string, enum: [pass, warn, fail] }
2168
+ detail: { type: string, nullable: true, description: "Why, in the user's language." }
2169
+ source: { type: string, enum: [deterministic, ai], description: Which producer wrote this check. }
2170
+ QaReport:
2171
+ type: object
2172
+ description: >-
2173
+ Pixel Check report for one asset version. `overall_score` is required
2174
+ because it already has a consumer: the API derives `Asset.pixel_check`
2175
+ from it. The object is open on purpose - a Guardian that learns to
2176
+ report more must not fail validation in clients that predate it.
2177
+ The Brand Guardian writes this on generation, logo regeneration and
2178
+ DNA refine, so a real report exists on many asset versions - no
2179
+ current app UI surfaces it, but that is a client-side choice, not a
2180
+ sign the field is unused.
2181
+ required: [overall_score]
2182
+ additionalProperties: true
2183
+ properties:
2184
+ overall_score: { type: integer, minimum: 0, maximum: 100 }
2185
+ checks:
2186
+ type: array
2187
+ items: { $ref: "#/components/schemas/QaCheck" }
2188
+ UsageBucket:
2189
+ type: object
2190
+ description: >-
2191
+ One time bucket of API-key usage. `requests` and `credits` are measures
2192
+ on different scales - a bucket can hold many cheap reads or one expensive
2193
+ generation - so a consumer must not plot them against a shared axis.
2194
+ required: [bucket, requests, credits]
2195
+ properties:
2196
+ bucket: { type: string, format: date-time, description: Start of the bucket, RFC 3339 UTC. }
2197
+ requests: { type: integer, minimum: 0 }
2198
+ credits: { type: integer, minimum: 0 }
2199
+ UsagePage:
2200
+ type: object
2201
+ description: >-
2202
+ Not cursor-paginated: the window is bounded by the from/to query rather
2203
+ than by a cursor, so `data` is the whole slice.
2204
+ required: [data]
2205
+ properties:
2206
+ data:
2207
+ type: array
2208
+ items: { $ref: "#/components/schemas/UsageBucket" }
2209
+ Subscription:
2210
+ type: object
2211
+ properties:
2212
+ plan: { type: string, enum: [free, pro, studio, agency] }
2213
+ status: { type: string, nullable: true }
2214
+ next_billed_at: { type: string, format: date-time, nullable: true }
2215
+ cancel_at_period_end: { type: boolean }
2216
+ pending_plan: { type: string, enum: [free, pro, studio, agency], nullable: true }
2217
+ plan_change_effective_at: { type: string, format: date-time, nullable: true }
2218
+ CheckoutSession:
2219
+ type: object
2220
+ required: [id, status, kind, plan, credits, amount, currency, created_at, settled_at]
2221
+ properties:
2222
+ id: { type: string }
2223
+ status: { type: string, enum: [pending, paid, failed, cancelled] }
2224
+ kind: { type: string, enum: [plan, topup] }
2225
+ plan: { type: string, enum: [free, pro, studio, agency], nullable: true }
2226
+ credits: { type: integer, nullable: true }
2227
+ amount:
2228
+ type: string
2229
+ nullable: true
2230
+ description: >-
2231
+ The catalogue list price frozen when this checkout was created -
2232
+ NOT the amount billed. Prices are tax-exclusive, so a customer in
2233
+ a taxed jurisdiction pays this plus tax (#425), and adaptive
2234
+ pricing may convert the currency. Read the invoice's total for the
2235
+ money that actually moved.
2236
+ currency:
2237
+ type: string
2238
+ nullable: true
2239
+ description: Currency of `amount`; USD for every catalogue price today.
2240
+ created_at: { type: string, format: date-time }
2241
+ settled_at: { type: string, format: date-time, nullable: true }
2242
+ CheckoutRequest:
2243
+ type: object
2244
+ required: [price_id]
2245
+ properties:
2246
+ price_id: { type: string, minLength: 1 }
2247
+ success_url:
2248
+ type: string
2249
+ format: uri
2250
+ description: >
2251
+ Absolute URL the provider returns the buyer to. Must be on the
2252
+ app's own origin (APP_BASE_URL); any other origin is rejected
2253
+ with 422 validation_failed rather than followed, so this field
2254
+ cannot be used as an open redirect. Idelio appends its own
2255
+ `checkout` outcome and `ref` query parameters to it.
2256
+ ChangePlanRequest:
2257
+ type: object
2258
+ required: [price_id]
2259
+ properties:
2260
+ price_id: { type: string, minLength: 1 }
2261
+ BillingCatalog:
2262
+ type: object
2263
+ properties:
2264
+ plans:
2265
+ type: object
2266
+ properties:
2267
+ pro: { type: string, nullable: true }
2268
+ studio: { type: string, nullable: true }
2269
+ agency: { type: string, nullable: true }
2270
+ topups:
2271
+ type: object
2272
+ properties:
2273
+ "100": { type: string, nullable: true }
2274
+ "300": { type: string, nullable: true }
2275
+ "1000": { type: string, nullable: true }
2276
+ AutoTopup:
2277
+ type: object
2278
+ properties:
2279
+ enabled: { type: boolean }
2280
+ pack_credits: { type: integer, enum: [100, 300, 1000], nullable: true }
2281
+ UpdateAutoTopupRequest:
2282
+ type: object
2283
+ required: [enabled]
2284
+ properties:
2285
+ enabled: { type: boolean }
2286
+ pack_credits:
2287
+ type: integer
2288
+ enum: [100, 300, 1000]
2289
+ description: Required when enabled is true.
2290
+ Template:
2291
+ type: object
2292
+ properties:
2293
+ id: { type: string }
2294
+ name: { type: string }
2295
+ category: { type: string }
2296
+ description: { type: string }
2297
+ prompt: { type: string }
2298
+ accent: { type: string }
2299
+ PaymentMethod:
2300
+ type: object
2301
+ properties:
2302
+ brand:
2303
+ type: string
2304
+ description: >
2305
+ The card network as the provider names it - "visa", "mastercard", "amex" and so on.
2306
+ Deliberately NOT an enum: providers add networks over time, so render a known mark
2307
+ when you recognise one and fall back to the raw value otherwise. It must stay
2308
+ readable as text, since an unrecognised brand with only an icon is a blank square.
2309
+ last4: { type: string }
2310
+ exp_month: { type: integer, minimum: 1, maximum: 12 }
2311
+ exp_year: { type: integer }
2312
+ Invoice:
2313
+ type: object
2314
+ properties:
2315
+ id: { type: string }
2316
+ status: { type: string }
2317
+ total:
2318
+ type: string
2319
+ nullable: true
2320
+ description: >
2321
+ The amount billed for this invoice after discounts, tax and any customer credit
2322
+ applied, as a string in the MINOR UNITS of `currency` - "2900" with a currency of
2323
+ "USD" is $29.00. Some currencies have no minor unit (JPY, KRW) and some have three
2324
+ (BHD, KWD), so derive the exponent from `currency` and never divide by 100. The
2325
+ integer-minor-unit encoding is established by Stripe's own SDK type, and the
2326
+ post-credit definition is what the provider maps (`amount_due`).
2327
+ currency:
2328
+ type: string
2329
+ nullable: true
2330
+ description: >
2331
+ ISO 4217 currency code. Its case is NOT normalised - Stripe returns it lowercase
2332
+ ("usd") - so compare it case-insensitively.
2333
+ billed_at: { type: string, format: date-time, nullable: true }
2334
+ invoice_number: { type: string, nullable: true }
2335
+ url:
2336
+ type: string
2337
+ format: uri
2338
+ nullable: true
2339
+ description: >
2340
+ Provider-hosted page where the customer can read or download this invoice, or null
2341
+ when the provider has not produced one - a draft has no hosted document yet. Render
2342
+ the absence rather than a dead link. The URL carries its own provider session and
2343
+ expires on the provider's terms, so treat it as a redirect target rather than
2344
+ something to embed or store.
2345
+ Workspace:
2346
+ type: object
2347
+ properties:
2348
+ id: { type: string }
2349
+ name: { type: string }
2350
+ plan: { type: string, enum: [free, pro, studio, agency] }
2351
+ settings: { type: object }
2352
+ UpdateWorkspaceRequest:
2353
+ type: object
2354
+ properties:
2355
+ name: { type: string, minLength: 1 }
2356
+ settings: { type: object }
2357
+ Member:
2358
+ type: object
2359
+ properties:
2360
+ id: { type: string }
2361
+ email: { type: string }
2362
+ role: { type: string, enum: [owner, admin, member] }
2363
+ created_at: { type: string, format: date-time }
2364
+ InviteMemberRequest:
2365
+ type: object
2366
+ required: [email, role]
2367
+ properties:
2368
+ email: { type: string, format: email }
2369
+ role: { type: string, enum: [admin, member] }
2370
+ ChangeMemberRoleRequest:
2371
+ type: object
2372
+ required: [role]
2373
+ properties:
2374
+ role: { type: string, enum: [owner, admin, member] }
2375
+ StripeWebhookEvent:
2376
+ type: object
2377
+ required: [id, type]
2378
+ description: >-
2379
+ Stripe's own event envelope - opaque/generic, not tightly typed here
2380
+ since we do not own Stripe's schema. The resource itself is nested
2381
+ under data.object.
2382
+ properties:
2383
+ id: { type: string }
2384
+ type: { type: string }
2385
+ data:
2386
+ type: object
2387
+ properties:
2388
+ object: { type: object }
2389
+ ErrorEnvelope:
2390
+ type: object
2391
+ required: [error]
2392
+ properties:
2393
+ error:
2394
+ type: object
2395
+ required: [type, code, message, request_id]
2396
+ properties:
2397
+ type: { type: string }
2398
+ code: { type: string }
2399
+ message: { type: string }
2400
+ param: { type: string }
2401
+ request_id: { type: string }
2402
+ docs_url: { type: string }
2403
+ Brand:
2404
+ type: object
2405
+ properties:
2406
+ id: { type: string }
2407
+ workspace_id: { type: string }
2408
+ name: { type: string }
2409
+ slug: { type: string }
2410
+ status: { type: string, enum: [draft, generating, ready, failed] }
2411
+ current_version_id: { type: string, nullable: true }
2412
+ locks: { type: object }
2413
+ preview:
2414
+ $ref: "#/components/schemas/BrandPreview"
2415
+ created_at: { type: string, format: date-time }
2416
+ updated_at: { type: string, format: date-time }
2417
+ BrandPreview:
2418
+ type: object
2419
+ description: >
2420
+ Visual summary for list and card views. Absent on reads that do not
2421
+ need it.
2422
+ properties:
2423
+ palette:
2424
+ type: array
2425
+ items: { type: string }
2426
+ description: >
2427
+ Every colour of the brand's current version, in the order its Brand
2428
+ DNA lists them - role-grouped by construction (text, surface, then
2429
+ accents, with any founder-chosen colour appended). The same array
2430
+ GET /brands/{id}/dna serves, so no two surfaces order it
2431
+ differently.
2432
+ cover: { type: string, nullable: true }
2433
+ asset_count: { type: integer, minimum: 0 }
2434
+ logo_url: { type: string, nullable: true }
2435
+ gradient_stops:
2436
+ type: array
2437
+ items: { type: string }
2438
+ description: >
2439
+ The two colours every surface draws this brand's gradient from -
2440
+ the DNA's first two accent-role tokens, the same ones the logo is
2441
+ drawn in. Empty before generation.
2442
+ BrandDna:
2443
+ type: object
2444
+ properties:
2445
+ brief:
2446
+ type: object
2447
+ properties:
2448
+ industry: { type: string }
2449
+ audience: { type: string }
2450
+ tone: { type: string }
2451
+ keywords: { type: array, items: { type: string } }
2452
+ constraints: { type: array, items: { type: string } }
2453
+ strategy:
2454
+ type: object
2455
+ properties:
2456
+ positioning: { type: string }
2457
+ archetype: { type: string }
2458
+ values: { type: array, items: { type: string } }
2459
+ name: { type: string }
2460
+ tagline: { type: string }
2461
+ name_rationale: { type: string }
2462
+ visual_system:
2463
+ type: object
2464
+ properties:
2465
+ palette:
2466
+ type: object
2467
+ properties:
2468
+ tokens:
2469
+ type: array
2470
+ items:
2471
+ type: object
2472
+ properties:
2473
+ name: { type: string }
2474
+ hex: { type: string }
2475
+ typography:
2476
+ type: object
2477
+ properties:
2478
+ heading_font: { type: string }
2479
+ body_font: { type: string }
2480
+ mono_font: { type: string, nullable: true }
2481
+ BrandVersionSummary:
2482
+ type: object
2483
+ properties:
2484
+ id: { type: string }
2485
+ version_no: { type: integer }
2486
+ message: { type: string, nullable: true }
2487
+ created_by: { type: string }
2488
+ qa_score: { type: integer, nullable: true }
2489
+ created_at: { type: string, format: date-time }
2490
+ is_current: { type: boolean }
2491
+ BrandSuggestionItem:
2492
+ type: object
2493
+ properties:
2494
+ kind: { type: string, enum: [logo_alternative, logo_wordmark, logo_mono, logo_reversed, favicon, brand_guide, profile_avatar, social_x, social_linkedin, social_linkedin_personal, social_facebook, social_youtube, social_post, social_story, app_icon, og_image, email_signature, hero_image, play_feature_graphic, business_card, letterhead, mockup, custom_image] }
2495
+ tag: { type: string }
2496
+ title: { type: string }
2497
+ desc: { type: string }
2498
+ state: { type: string, enum: [idle, generating, ready] }
2499
+ asset_id: { type: string, nullable: true }
2500
+ custom:
2501
+ nullable: true
2502
+ description: "Set exactly when kind is custom_image - an idea offered only once the brand owns every catalogue kind it can be offered. It is never generated in one click: prefill POST /brands/{id}/assets (custom_image_name, custom_image_prompt, custom_image_format) and let the user review it."
2503
+ allOf:
2504
+ - $ref: "#/components/schemas/CustomSuggestion"
2505
+ CustomSuggestion:
2506
+ type: object
2507
+ required: [name, prompt, format]
2508
+ properties:
2509
+ name: { type: string, minLength: 1, maxLength: 60 }
2510
+ prompt: { type: string, minLength: 1, maxLength: 500 }
2511
+ format: { type: string, enum: [square_1_1, portrait_4_5, story_9_16, landscape_16_9, wide_21_9, photo_3_2, poster_2_3, standard_4_3, auto] }
2512
+ WorkspaceSuggestionItem:
2513
+ allOf:
2514
+ - $ref: "#/components/schemas/BrandSuggestionItem"
2515
+ - type: object
2516
+ required: [job_id, brand]
2517
+ properties:
2518
+ job_id: { type: string, nullable: true, description: "The job behind a generating item, null otherwise." }
2519
+ brand:
2520
+ type: object
2521
+ required: [id, name]
2522
+ properties:
2523
+ id: { type: string }
2524
+ name: { type: string }
2525
+ preview: { $ref: "#/components/schemas/BrandPreview" }
2526
+ CreativeDirectionsRequest:
2527
+ type: object
2528
+ required: [prompt]
2529
+ properties:
2530
+ prompt:
2531
+ type: string
2532
+ minLength: 8
2533
+ maxLength: 500
2534
+ description: The founder's own description of the brand - what the ranking is against.
2535
+ styles:
2536
+ type: array
2537
+ description: The style chips already picked, if any - catalog ids, not labels.
2538
+ items: { $ref: "#/components/schemas/CreativeDirection/properties/styles/items" }
2539
+ CreativeDirectionsShortlist:
2540
+ type: object
2541
+ required: [palettes, font_pairs, logo_styles]
2542
+ description: >-
2543
+ Catalog ids, best fit first, padded from the catalog's own order so a
2544
+ section is never half empty. Enums are generated from the TypeScript
2545
+ catalogs by scripts/sync-creative-direction-enums.mjs - do not edit by
2546
+ hand.
2547
+ properties:
2548
+ palettes:
2549
+ type: array
2550
+ items: { type: string, enum: [indigo, teal, amber, forest, rose, slate, midnight, coral, sand, plum, ocean, citrus, mono_warm, berry] }
2551
+ font_pairs:
2552
+ type: array
2553
+ items: { type: string, enum: [sora_inter, fraunces_inter, space_inter, playfair_source, dmserif_dmsans, outfit_inter, manrope_manrope, bricolage_inter, instrument_inter, archivo_archivo, lora_lato, syne_inter, epilogue_inter, ibmplex_ibmplex, jost_inter, newsreader_inter, chivo_chivo, unbounded_inter, figtree_figtree, spectral_inter] }
2554
+ logo_styles:
2555
+ type: array
2556
+ items: { type: string, enum: [geometric, rounded, circular, sharp, monogram, lettermark, abstract, emblem, organic, line, gradient, wordmark_only] }
2557
+ rationale:
2558
+ type: string
2559
+ maxLength: 200
2560
+ description: One line on why the top choices fit - shown as a caption, never parsed.
2561
+ NameIdeasRequest:
2562
+ type: object
2563
+ required: [style]
2564
+ properties:
2565
+ style:
2566
+ type: string
2567
+ enum: [compound, abstract, evocative, playful]
2568
+ keyword: { type: string, maxLength: 80 }
2569
+ description: { type: string, maxLength: 500 }
2570
+ NameIdea:
2571
+ type: object
2572
+ required: [name, hint]
2573
+ properties:
2574
+ name: { type: string }
2575
+ hint:
2576
+ type: string
2577
+ description: One line on why the name works - the only thing that distinguishes two invented words to a user.
2578
+ NameIdeasResponse:
2579
+ type: object
2580
+ required: [items]
2581
+ properties:
2582
+ items:
2583
+ type: array
2584
+ items: { $ref: "#/components/schemas/NameIdea" }
2585
+ SurpriseBriefResponse:
2586
+ type: object
2587
+ properties:
2588
+ name: { type: string }
2589
+ tagline: { type: string }
2590
+ description: { type: string }
2591
+ styles:
2592
+ type: array
2593
+ description: >-
2594
+ Catalog ids, not display labels - the creator seeds its style chips
2595
+ with these and matches them against the same ids.
2596
+ items: { type: string, enum: [modern, minimal, bold, playful, elegant, corporate, luxury, organic, geometric, handcrafted, brutalist, editorial, retro, vintage, futuristic, timeless, art_deco, y2k, calm, energetic, warm, technical, rebellious, premium] }
2597
+ JobStep:
2598
+ type: object
2599
+ properties:
2600
+ step:
2601
+ type: string
2602
+ enum: [intake, strategy, visual_system, logo, brand_guardian, applications, render_package, persist_deliver, color_palette, font_pairing, logo_alternative, logo_mono, logo_reversed, logo_wordmark, favicon, brand_guide, business_card, letterhead, mockup, app_icon, og_image, email_signature, hero_image, play_feature_graphic, profile_avatar, social_x, social_linkedin, social_linkedin_personal, social_facebook, social_youtube, social_post, social_story, custom_image, packaging, dna_refine]
2603
+ status: { type: string, enum: [pending, running, completed, failed, skipped] }
2604
+ started_at: { type: string, format: date-time, nullable: true }
2605
+ completed_at: { type: string, format: date-time, nullable: true }
2606
+ GenerationJob:
2607
+ type: object
2608
+ properties:
2609
+ id: { type: string }
2610
+ brand_id: { type: string }
2611
+ kind: { type: string, enum: [brand_generation, asset_regeneration, brand_expand, brand_export, brand_bundle, brand_dna_refine] }
2612
+ workflow_id: { type: string }
2613
+ team_tier: { type: string, enum: [flash, studio, elite] }
2614
+ credit_multiplier: { type: number }
2615
+ status: { type: string, enum: [queued, running, completed, completed_with_errors, failed, canceled] }
2616
+ steps:
2617
+ type: array
2618
+ items: { $ref: "#/components/schemas/JobStep" }
2619
+ credits_reserved: { type: integer }
2620
+ credits_used: { type: integer, nullable: true }
2621
+ eta_seconds: { type: integer, nullable: true }
2622
+ download_url: { type: string, nullable: true }
2623
+ created_at: { type: string, format: date-time }
2624
+ JobEnvelope:
2625
+ type: object
2626
+ properties:
2627
+ job: { $ref: "#/components/schemas/GenerationJob" }
2628
+ LogoSelectionRequest:
2629
+ type: object
2630
+ properties:
2631
+ asset_version_id: { type: string }
2632
+ lockup_override:
2633
+ type: string
2634
+ enum: [horizontal, stacked]
2635
+ description: >-
2636
+ Changes ONLY this approval's logo_alternative render - never the
2637
+ brand's own pinned Logo direction.
2638
+ family_override:
2639
+ type: string
2640
+ description: >-
2641
+ Changes ONLY this approval's logo_alternative wordmark font -
2642
+ never the brand's own typography.heading_font DNA value.
2643
+ ink_override:
2644
+ type: string
2645
+ description: >-
2646
+ Changes ONLY this approval's logo_alternative wordmark ink
2647
+ (main text) colour - never the brand's own DNA palette
2648
+ (color_palette asset). Must be #rrggbb and one of the brand's
2649
+ own generated palette tokens.
2650
+ accent_override:
2651
+ type: string
2652
+ description: >-
2653
+ Changes ONLY this approval's logo_alternative wordmark accent
2654
+ (highlighted run) colour - never the brand's own DNA palette
2655
+ (color_palette asset). Must be #rrggbb and one of the brand's
2656
+ own generated palette tokens.
2657
+ style_seed_override:
2658
+ type: string
2659
+ description: >-
2660
+ Carries forward the "shuffle wordmark style" control's last
2661
+ value so approving does not silently re-roll the treatment the
2662
+ user picked.
2663
+ LogoConceptPalette:
2664
+ type: object
2665
+ properties:
2666
+ tokens:
2667
+ type: array
2668
+ items:
2669
+ type: object
2670
+ properties:
2671
+ hex: { type: string }
2672
+ role: { type: string }
2673
+ # The brand's generated name, from the same concept recipe the tokens
2674
+ # come from. Null on a batch persisted before the strategy was stored
2675
+ # with it. The brand row itself carries only a working name derived
2676
+ # from the prompt until persist_brand_version runs, which is the last
2677
+ # activity before the job completes - so this is the only place the
2678
+ # real name is readable while the concept picker is on screen.
2679
+ brand_name: { type: string, nullable: true }
2680
+ Asset:
2681
+ type: object
2682
+ properties:
2683
+ id: { type: string }
2684
+ brand_id: { type: string }
2685
+ version_id: { type: string, nullable: true }
2686
+ kind: { type: string }
2687
+ name: { type: string }
2688
+ status: { type: string, enum: [queued, generating, ready, failed] }
2689
+ url: { type: string, nullable: true }
2690
+ thumbnail_url: { type: string, nullable: true }
2691
+ credits: { type: integer }
2692
+ pixel_check: { type: integer, nullable: true }
2693
+ qa_report:
2694
+ allOf: [{ $ref: "#/components/schemas/QaReport" }]
2695
+ nullable: true
2696
+ formats:
2697
+ description: >-
2698
+ Downloadable file formats of the effective version, normalized for
2699
+ display (png-1024 and png-256 collapse to PNG). The effective
2700
+ version is version_id's row, or - where an asset has versions but
2701
+ none is current yet (logo concepts awaiting selection) - the latest
2702
+ one, so this can be non-empty while version_id is still null. Empty
2703
+ when the asset has no version at all.
2704
+ type: array
2705
+ items: { type: string }
2706
+ created_at: { type: string, format: date-time }
2707
+ AssetVersion:
2708
+ type: object
2709
+ properties:
2710
+ id: { type: string }
2711
+ asset_id: { type: string }
2712
+ parent_version_id: { type: string, nullable: true }
2713
+ files:
2714
+ type: array
2715
+ items:
2716
+ type: object
2717
+ properties:
2718
+ format: { type: string }
2719
+ content_hash: { type: string }
2720
+ storage_key: { type: string }
2721
+ bytes: { type: integer }
2722
+ credit_cost: { type: integer }
2723
+ qa_report:
2724
+ allOf: [{ $ref: "#/components/schemas/QaReport" }]
2725
+ nullable: true
2726
+ created_at: { type: string, format: date-time }
2727
+ RefineRequest:
2728
+ description: >-
2729
+ A free-text instruction, accepted by `logo` and `logo_wordmark`, or
2730
+ structured treatment overrides, accepted by `logo_wordmark` only.
2731
+ oneOf:
2732
+ - type: object
2733
+ required: [instruction]
2734
+ properties:
2735
+ instruction: { type: string, minLength: 1, maxLength: 500 }
2736
+ - $ref: "#/components/schemas/LogoTreatmentOverrides"
2737
+ VariationRequest:
2738
+ type: object
2739
+ required: [preset]
2740
+ properties:
2741
+ preset:
2742
+ type: string
2743
+ enum:
2744
+ - more_minimal
2745
+ - more_startup
2746
+ - bolder
2747
+ - playful
2748
+ - elegant
2749
+ - geometric
2750
+ - hand_drawn
2751
+ - monochrome
2752
+ RestoreRequest:
2753
+ type: object
2754
+ required: [to_version]
2755
+ properties:
2756
+ to_version: { type: string }
2757
+ LogoTreatmentOverrides:
2758
+ type: object
2759
+ description: >-
2760
+ Structured overrides for wordmark treatment - font, ink (name) colour,
2761
+ accent colour and style variant. At least one field is required.
2762
+ properties:
2763
+ family: { type: string, minLength: 1, maxLength: 200 }
2764
+ ink: { type: string, pattern: "^#[0-9a-fA-F]{6}$" }
2765
+ accent: { type: string, pattern: "^#[0-9a-fA-F]{6}$" }
2766
+ styleSeed: { type: string, minLength: 1, maxLength: 64 }
2767
+ styleIndex:
2768
+ type: integer
2769
+ minimum: 0
2770
+ maximum: 10
2771
+ description: >-
2772
+ Names one of the category's 5 wordmark treatments outright.
2773
+ Preferred over styleSeed, which re-rolls: a nonce lands on the
2774
+ treatment already in force one time in five and names nothing
2775
+ a caller can return to. Persisted on the asset recipe, so
2776
+ every later render of the brand reproduces it.
2777
+ LogoTreatment:
2778
+ type: object
2779
+ description: >-
2780
+ The wordmark/logo's current treatment (font, ink, accent, style) -
2781
+ read from the brand's logo asset's own current-version recipe, the
2782
+ durable source of truth. Provides pre-fill values for the wordmark
2783
+ treatment panel.
2784
+ required: [family, ink, accent, style_seed, style_index]
2785
+ properties:
2786
+ family: { type: string }
2787
+ ink: { type: string, pattern: "^#[0-9a-fA-F]{6}$" }
2788
+ accent: { type: string, pattern: "^#[0-9a-fA-F]{6}$" }
2789
+ style_seed: { type: string }
2790
+ style_index:
2791
+ type: integer
2792
+ minimum: 0
2793
+ maximum: 10
2794
+ nullable: true
2795
+ description: >-
2796
+ Which of the category's 11 wordmark treatments is currently
2797
+ drawn, or null for a brand that has never named one (the
2798
+ renderer then picks by hash, as it always did).
2799
+ MeResponse:
2800
+ type: object
2801
+ properties:
2802
+ user:
2803
+ type: object
2804
+ properties:
2805
+ id: { type: string }
2806
+ clerkUserId: { type: string, nullable: true }
2807
+ email: { type: string, nullable: true }
2808
+ termsAcceptedAt:
2809
+ type: string
2810
+ format: date-time
2811
+ nullable: true
2812
+ description: >-
2813
+ When this membership first accepted the Terms of Service, or null
2814
+ if no acceptance is on record. Scoped to the workspace membership
2815
+ rather than the person. Always null for an API key, which has no
2816
+ membership to read. Reports the earliest acceptance, so it does
2817
+ not track later revisions of the Terms.
2818
+ workspace:
2819
+ type: object
2820
+ properties:
2821
+ id: { type: string }
2822
+ clerkOrgId: { type: string }
2823
+ name: { type: string }
2824
+ plan: { type: string }
2825
+ role: { type: string, enum: [owner, admin, member] }
2826
+ AiTeam:
2827
+ type: object
2828
+ properties:
2829
+ tier: { type: string, enum: [flash, studio, elite] }
2830
+ credit_multiplier: { type: number }
2831
+ WaitlistSignupRequest:
2832
+ type: object
2833
+ required: [email]
2834
+ properties:
2835
+ email: { type: string, format: email }
2836
+ marketing_consent: { type: boolean, default: false }
2837
+ privacy_policy_version: { type: string }
2838
+ attribution: { $ref: "#/components/schemas/WaitlistAttribution" }
2839
+ WaitlistAttribution:
2840
+ description: >-
2841
+ First-touch acquisition snapshot captured on the landing page:
2842
+ utm_* / ad click ids from the entry URL, the external referrer and
2843
+ the entry path. All fields optional; unknown fields are stripped and
2844
+ never persisted.
2845
+ type: object
2846
+ properties:
2847
+ utm_source: { type: string, minLength: 1, maxLength: 512 }
2848
+ utm_medium: { type: string, minLength: 1, maxLength: 512 }
2849
+ utm_campaign: { type: string, minLength: 1, maxLength: 512 }
2850
+ utm_term: { type: string, minLength: 1, maxLength: 512 }
2851
+ utm_content: { type: string, minLength: 1, maxLength: 512 }
2852
+ gclid: { type: string, minLength: 1, maxLength: 512 }
2853
+ fbclid: { type: string, minLength: 1, maxLength: 512 }
2854
+ referrer: { type: string, minLength: 1, maxLength: 512 }
2855
+ landing_page: { type: string, minLength: 1, maxLength: 512 }
2856
+ WaitlistSignupResponse:
2857
+ type: object
2858
+ properties:
2859
+ status: { type: string, enum: [joined] }
2860
+ ConsentCookieRequest:
2861
+ type: object
2862
+ required: [type, region, categories, banner_version, gpc, client_id]
2863
+ properties:
2864
+ type: { type: string, enum: [cookie_consent] }
2865
+ region: { type: string, minLength: 1, maxLength: 16 }
2866
+ categories:
2867
+ type: array
2868
+ maxItems: 16
2869
+ items: { type: string, minLength: 1, maxLength: 64 }
2870
+ banner_version: { type: integer, minimum: 0 }
2871
+ gpc: { type: boolean }
2872
+ client_id: { type: string, minLength: 1, maxLength: 128 }
2873
+ ConsentPrivacyChoiceRequest:
2874
+ type: object
2875
+ required: [type, region, choices, gpc, client_id]
2876
+ properties:
2877
+ type: { type: string, enum: [privacy_choice] }
2878
+ region: { type: string, minLength: 1, maxLength: 16 }
2879
+ choices:
2880
+ type: object
2881
+ additionalProperties: { type: boolean }
2882
+ gpc: { type: boolean }
2883
+ client_id: { type: string, minLength: 1, maxLength: 128 }
2884
+ ConsentTermsAcceptanceRequest:
2885
+ type: object
2886
+ required: [type, checkbox_text, accepted_versions, client_id]
2887
+ properties:
2888
+ type: { type: string, enum: [terms_acceptance] }
2889
+ checkbox_text: { type: string, minLength: 1, maxLength: 512 }
2890
+ accepted_versions:
2891
+ type: object
2892
+ additionalProperties: { type: string, maxLength: 32 }
2893
+ client_id: { type: string, minLength: 1, maxLength: 128 }
2894
+ ConsentPurchaseDisclosureRequest:
2895
+ type: object
2896
+ description: >-
2897
+ What the checkout confirmation step displayed before the buyer approved
2898
+ the purchase (#424). A record of a DISCLOSURE, not of a permission -
2899
+ pressing the pay button is the agreement, and no separate tick for the
2900
+ payment or the renewal is shown. Recorded before the redirect to the
2901
+ payment provider, so `recorded_at` is when the buyer approved, not when
2902
+ the money moved.
2903
+ required:
2904
+ [type, idempotency_key, price_id, kind, amount, currency, billing_period,
2905
+ disclosure_version, client_id]
2906
+ properties:
2907
+ type: { type: string, enum: [purchase_disclosure] }
2908
+ idempotency_key:
2909
+ type: string
2910
+ minLength: 1
2911
+ maxLength: 128
2912
+ description: >-
2913
+ The Idempotency-Key the checkout request carries - what ties this
2914
+ record to one purchase attempt. Not a checkout session id: the
2915
+ session is created by the request this disclosure precedes.
2916
+ price_id: { type: string, minLength: 1, maxLength: 255 }
2917
+ kind: { type: string, enum: [plan, topup] }
2918
+ plan: { type: string, enum: [pro, studio, agency] }
2919
+ credits: { type: integer, enum: [100, 300, 1000] }
2920
+ amount: { type: string, minLength: 1, maxLength: 32 }
2921
+ currency: { type: string, minLength: 1, maxLength: 8 }
2922
+ billing_period: { type: string, enum: [month, one_time] }
2923
+ renewal_text:
2924
+ type: string
2925
+ minLength: 1
2926
+ maxLength: 512
2927
+ description: >-
2928
+ The automatic-renewal sentence as shown. Stored rather than
2929
+ derived, so a later wording change cannot rewrite what a past buyer
2930
+ was told. Absent for a one-time pack, which renews nothing.
2931
+ disclosure_version: { type: integer, minimum: 0 }
2932
+ client_id: { type: string, minLength: 1, maxLength: 128 }
2933
+ ConsentRequest:
2934
+ oneOf:
2935
+ - { $ref: "#/components/schemas/ConsentCookieRequest" }
2936
+ - { $ref: "#/components/schemas/ConsentPrivacyChoiceRequest" }
2937
+ - { $ref: "#/components/schemas/ConsentTermsAcceptanceRequest" }
2938
+ - { $ref: "#/components/schemas/ConsentPurchaseDisclosureRequest" }
2939
+ discriminator:
2940
+ propertyName: type
2941
+ mapping:
2942
+ cookie_consent: "#/components/schemas/ConsentCookieRequest"
2943
+ privacy_choice: "#/components/schemas/ConsentPrivacyChoiceRequest"
2944
+ terms_acceptance: "#/components/schemas/ConsentTermsAcceptanceRequest"
2945
+ purchase_disclosure: "#/components/schemas/ConsentPurchaseDisclosureRequest"
2946
+ ConsentResponse:
2947
+ type: object
2948
+ required: [id, recorded_at]
2949
+ properties:
2950
+ id: { type: string }
2951
+ recorded_at: { type: string, format: date-time }
2952
+ ApiKey:
2953
+ type: object
2954
+ properties:
2955
+ id: { type: string }
2956
+ name: { type: string }
2957
+ environment: { type: string, enum: [live, test] }
2958
+ prefix: { type: string }
2959
+ last4: { type: string }
2960
+ requests: { type: integer }
2961
+ credits: { type: integer }
2962
+ created_at: { type: string, format: date-time }
2963
+ last_used_at: { type: string, format: date-time, nullable: true }
2964
+ created_by_email: { type: string, nullable: true, description: "Null for keys created before this field existed." }
2965
+ ApiKeyWithSecret:
2966
+ allOf:
2967
+ - { $ref: "#/components/schemas/ApiKey" }
2968
+ - type: object
2969
+ properties:
2970
+ secret: { type: string }
2971
+ CreateApiKeyRequest:
2972
+ type: object
2973
+ required: [name]
2974
+ properties:
2975
+ name: { type: string, minLength: 1, maxLength: 80 }
2976
+ environment:
2977
+ type: string
2978
+ enum: [live, test]
2979
+ default: live
2980
+ description: >
2981
+ A label only - a test key reaches the real workspace and spends
2982
+ real credits exactly like a live one.
2983
+ WebhookEndpoint:
2984
+ type: object
2985
+ properties:
2986
+ id: { type: string }
2987
+ url: { type: string, format: uri }
2988
+ events:
2989
+ type: array
2990
+ items: { $ref: "#/components/schemas/WebhookEventType" }
2991
+ disabled: { type: boolean }
2992
+ created_at: { type: string, format: date-time }
2993
+ WebhookEndpointWithSecret:
2994
+ allOf:
2995
+ - type: object
2996
+ properties:
2997
+ endpoint: { $ref: "#/components/schemas/WebhookEndpoint" }
2998
+ secret: { type: string }
2999
+ CreateWebhookEndpointRequest:
3000
+ type: object
3001
+ required: [url, events]
3002
+ properties:
3003
+ url: { type: string, format: uri }
3004
+ events:
3005
+ type: array
3006
+ minItems: 1
3007
+ items: { $ref: "#/components/schemas/WebhookEventType" }
3008
+ UpdateWebhookEndpointRequest:
3009
+ type: object
3010
+ properties:
3011
+ events:
3012
+ type: array
3013
+ minItems: 1
3014
+ items: { $ref: "#/components/schemas/WebhookEventType" }
3015
+ disabled: { type: boolean }
3016
+ WebhookEventType:
3017
+ type: string
3018
+ enum:
3019
+ - brand.completed
3020
+ - brand.updated
3021
+ - asset.ready
3022
+ - asset.regenerated
3023
+ - job.failed
3024
+ - credits.low
3025
+ - credits.settled
3026
+ - subscription.changed