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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/template-image-variants.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@passmint/node': minor
---

Add template image variants. `templates.uploadImage(id, slot, data, { variant })` uploads a PNG/JPEG (`Uint8Array`, `ArrayBuffer` or `Buffer`, base64-encoded without depending on `Buffer`, so it works on Workers) and `templates.deleteImage(id, slot, { variant })` removes one. `passes.create` and `passes.update` accept `imageVariant` (`null` on update clears it). `Pass` gains `image_variant`, `Template` gains `image_variants`, and the `TemplateImage`, `TemplateImageSlot`, `VariantImageSlot` and `TemplateImageOptions` types are exported.

Also add the optional `changeMessage` to `TemplateField`: a `%@` format string that makes Apple Wallet show a lock-screen notification when the field changes on update.

Add per-pass images. `passes.uploadImage(id, slot, data)` gives one pass its own `strip`, `thumbnail` or `background` image (PNG/JPEG/WebP, up to 8 MB), overriding its variant and template image, and `passes.deleteImage(id, slot)` removes it. Both return the updated `Pass`. `Pass` gains `images`, which reports whether each resolved slot comes from the pass, its variant or the template, and `EventPass` gains `image_variant`. The `PassImageSlot`, `PassImageSource` and `PassImages` types are exported.
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,12 +132,31 @@ API keys starting with `pmk_test_` run against test mode, `pmk_live_` run agains
| `list(params?)` | List passes, filter by template or holder email. |
| `update(id, params, options?)` | Update field values or metadata on a pass. |
| `void(id)` | Void a pass. |
| `uploadImage(id, slot, data)` | Give this pass its own `strip`, `thumbnail` or `background` image. Returns the updated pass. |
| `deleteImage(id, slot)` | Remove the pass's own image for a slot. Returns the updated pass. |
| `events(id)` | List raw lifecycle events for a pass (internal vocabulary, e.g. `installed`). Use `passmint.events` for the canonical `pass.*` stream. |

`create` and `update` accept an optional `idempotencyKey` in `options`. If omitted, the SDK generates one for you so retries are safe by default.

`create` also accepts `platforms` (`['apple', 'google']`) and `certificateSetId` overrides. The created pass includes `warnings` for platforms that could not be delivered.

`create` and `update` accept `imageVariant` to render the pass with one of its template's image variants (see below). On `update`, `null` clears it back to the base images.

#### Per-pass images

A pass can have its own `strip`, `thumbnail` or `background` image, such as a member photo:

```ts
import { readFile } from 'node:fs/promises'

const photoBytes = await readFile('./members/ada.jpg')
await passmint.passes.uploadImage(pass.id, 'thumbnail', photoBytes)
```

Each slot resolves in three layers: the pass's own image wins, then the template image variant the pass uses (`imageVariant`), then the template's base image. `pass.images` reports the source of each resolved slot (`{ thumbnail: { source: 'pass' }, logo: { source: 'template' } }`). `deleteImage(pass.id, 'thumbnail')` removes the override so the slot falls back to the next layer. Use it to erase a holder's photo for privacy requests too.

Images are PNG, JPEG or WebP, up to 8 MB. Both methods re-render the pass and push it to wallets. Which slots are allowed depends on the template's Apple style; a slot the style doesn't support fails with a 400 `image_slot_not_allowed` error, and updating a voided pass fails with a 409. On Google Wallet, `thumbnail` shows as the avatar on generic passes and as a card image on the other pass types.

### Templates — `passmint.templates`

| Method | Description |
Expand All @@ -147,6 +166,34 @@ API keys starting with `pmk_test_` run against test mode, `pmk_live_` run agains
| `list()` | List all templates. |
| `update(id, params)` | Update name, design, platforms, credentials, or archive state. |
| `archive(id)` | Archive a template. |
| `uploadImage(id, slot, data, options?)` | Upload a PNG/JPEG (`Uint8Array`, `ArrayBuffer` or `Buffer`) to an image slot, optionally as a named `variant`. |
| `deleteImage(id, slot, options?)` | Remove a slot's image, or one named `variant` of it. |

Template fields accept an optional `changeMessage`, a format string containing `%@` (e.g. `'You now have %@ stamps'`). When that field's value changes on a pass update, Apple Wallet shows it as a lock-screen notification with `%@` replaced by the new value. Fields without one update silently.

Image slots are `icon`, `logo`, `strip`, `thumbnail`, `background` and `footer`. The `strip`, `thumbnail` and `background` slots can also hold named variants (`^[a-z0-9][a-z0-9_-]{0,31}$`), listed on `template.image_variants`. A pass picks one with `imageVariant`, so the image can change without a separate template per state. Asking for a variant the template doesn't have fails with a 400 `unknown_image_variant` error.

For example, a stamp card with one strip per stamp count. Upload the strips once:

```ts
import { readFile } from 'node:fs/promises'

for (let count = 0; count <= 10; count++) {
const strip = await readFile(`./strips/stamps-${count}.png`)
await passmint.templates.uploadImage(templateId, 'strip', strip, { variant: String(count) })
}
```

Then switch the strip each time a stamp is added:

```ts
await passmint.passes.update(passId, {
fieldValues: { stamps: String(count) },
imageVariant: String(count),
})
```

The SDK base64-encodes the bytes itself without depending on `Buffer`, so `uploadImage` runs on Node, Cloudflare Workers, Deno and Bun.

### Events — `passmint.events`

Expand Down
3 changes: 3 additions & 0 deletions ecosystem-tests/mock-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ function pass(id, overrides = {}) {
url: 'https://passmint.com/p/shrt_abc',
download_url: 'https://passmint.com/p/shrt_abc/download',
google_wallet_url: null,
image_variant: null,
images: { icon: { source: 'template' } },
created_at: '2026-07-01T12:00:00.000Z',
...overrides,
}
Expand Down Expand Up @@ -80,6 +82,7 @@ function canonicalEvent(id) {
url: 'https://passmint.com/p/shrt_abc',
download_url: 'https://passmint.com/p/shrt_abc/download',
google_wallet_url: null,
image_variant: null,
},
},
},
Expand Down
2 changes: 1 addition & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export interface PassmintOptions {
}

export interface InternalRequest {
method: 'GET' | 'POST' | 'PATCH' | 'DELETE'
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
path: string
query?: Record<string, string | number | undefined>
body?: unknown
Expand Down
18 changes: 18 additions & 0 deletions src/encoding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Chunk size for String.fromCharCode.apply — keeps us well under engine
// argument-count limits for multi-megabyte images.
const CHUNK = 0x8000

/**
* Base64-encode raw bytes without relying on Node's `Buffer`, so it works the
* same on Node, Cloudflare Workers, Deno and Bun (all expose `btoa`). A Node
* `Buffer` is a `Uint8Array`, so it is accepted as-is (respecting its offset
* into any shared pool).
*/
export function toBase64(data: Uint8Array | ArrayBuffer): string {
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data)
let binary = ''
for (let i = 0; i < bytes.length; i += CHUNK) {
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK) as unknown as number[])
}
return btoa(binary)
}
7 changes: 7 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ export type {
MinimizedHolder,
Pass,
PassEvent,
PassImages,
PassImageSlot,
PassImageSource,
PassmintEvent,
PassmintEventType,
PassmintMode,
Expand All @@ -44,10 +47,14 @@ export type {
Template,
TemplateDesign,
TemplateField,
TemplateImage,
TemplateImageOptions,
TemplateImageSlot,
TemplateType,
UpdatePassParams,
UpdateTemplateParams,
UpdateWebhookParams,
VariantImageSlot,
WalletPlatform,
Webhook,
WebhookDelivery,
Expand Down
41 changes: 37 additions & 4 deletions src/resources/passes.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { type PassmintHttpClient, generateIdempotencyKey } from '../client'
import { toBase64 } from '../encoding'
import type {
CreatePassParams,
ListPassesParams,
ListResponse,
Pass,
PassEvent,
PassImageSlot,
RequestOptions,
UpdatePassParams,
} from '../types'
Expand All @@ -27,6 +29,7 @@ export class PassesResource {
// override (dev cert), so only omit the key when truly unset.
if (params.certificateSetId !== undefined) body.certificate_set_id = params.certificateSetId
if (params.platforms !== undefined) body.platforms = params.platforms
if (params.imageVariant !== undefined) body.image_variant = params.imageVariant
return this.http.request<Pass & { warnings: string[] }>({
method: 'POST',
path: '/v1/passes',
Expand Down Expand Up @@ -55,13 +58,16 @@ export class PassesResource {
}

update(id: string, params: UpdatePassParams, options: RequestOptions = {}): Promise<Pass> {
const body: Record<string, unknown> = {}
if (params.fieldValues !== undefined) body.field_values = params.fieldValues
if (params.metadata !== undefined) body.metadata = params.metadata
// null clears the variant back to the base images, so only omit the key
// when truly unset.
if (params.imageVariant !== undefined) body.image_variant = params.imageVariant
return this.http.request<Pass>({
method: 'PATCH',
path: `/v1/passes/${encodeURIComponent(id)}`,
body: {
field_values: params.fieldValues,
metadata: params.metadata,
},
body,
idempotencyKey: options.idempotencyKey ?? generateIdempotencyKey(),
})
}
Expand All @@ -73,6 +79,33 @@ export class PassesResource {
})
}

/**
* Upload a PNG, JPEG or WebP (max 8 MB) as this pass's own image for a slot,
* overriding its template variant and base image. The pass is re-rendered
* and pushed to wallets; resolves with the updated pass. Re-uploading
* replaces the override.
*/
uploadImage(passId: string, slot: PassImageSlot, data: Uint8Array | ArrayBuffer): Promise<Pass> {
// PUT is idempotent, so no Idempotency-Key.
return this.http.request<Pass>({
method: 'PUT',
path: `/v1/passes/${encodeURIComponent(passId)}/images/${encodeURIComponent(slot)}`,
body: { data: toBase64(data) },
})
}

/**
* Remove this pass's image override for a slot, falling back to its
* template variant or base image. Also use it to erase a holder's photo.
* Resolves with the updated pass.
*/
deleteImage(passId: string, slot: PassImageSlot): Promise<Pass> {
return this.http.request<Pass>({
method: 'DELETE',
path: `/v1/passes/${encodeURIComponent(passId)}/images/${encodeURIComponent(slot)}`,
})
}

events(id: string): Promise<ListResponse<PassEvent>> {
return this.http.request<ListResponse<PassEvent>>({
method: 'GET',
Expand Down
58 changes: 58 additions & 0 deletions src/resources/templates.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { type PassmintHttpClient, generateIdempotencyKey } from '../client'
import { toBase64 } from '../encoding'
import type {
CreateTemplateParams,
ListResponse,
RequestOptions,
Template,
TemplateImage,
TemplateImageOptions,
TemplateImageSlot,
UpdateTemplateParams,
VariantImageSlot,
} from '../types'

export class TemplatesResource {
Expand Down Expand Up @@ -64,4 +69,57 @@ export class TemplatesResource {
path: `/v1/templates/${encodeURIComponent(id)}`,
})
}

/**
* Upload a PNG or JPEG to a template image slot. With `options.variant`,
* stores a named variant (strip / thumbnail / background only) that passes
* can opt into via `imageVariant`. Re-uploading replaces the existing image.
*/
uploadImage(
templateId: string,
slot: VariantImageSlot,
data: Uint8Array | ArrayBuffer,
options?: TemplateImageOptions,
): Promise<TemplateImage>
uploadImage(
templateId: string,
slot: TemplateImageSlot,
data: Uint8Array | ArrayBuffer,
): Promise<TemplateImage>
uploadImage(
templateId: string,
slot: TemplateImageSlot,
data: Uint8Array | ArrayBuffer,
options: TemplateImageOptions = {},
): Promise<TemplateImage> {
const body: Record<string, unknown> = { data: toBase64(data) }
if (options.variant !== undefined) body.variant = options.variant
return this.http.request<TemplateImage>({
method: 'PUT',
path: `/v1/templates/${encodeURIComponent(templateId)}/images/${encodeURIComponent(slot)}`,
body,
})
}

/** Remove a template image, or one named variant of it with `options.variant`. */
deleteImage(
templateId: string,
slot: VariantImageSlot,
options?: TemplateImageOptions,
): Promise<TemplateImage & { deleted: true }>
deleteImage(
templateId: string,
slot: TemplateImageSlot,
): Promise<TemplateImage & { deleted: true }>
deleteImage(
templateId: string,
slot: TemplateImageSlot,
options: TemplateImageOptions = {},
): Promise<TemplateImage & { deleted: true }> {
return this.http.request<TemplateImage & { deleted: true }>({
method: 'DELETE',
path: `/v1/templates/${encodeURIComponent(templateId)}/images/${encodeURIComponent(slot)}`,
query: { variant: options.variant },
})
}
}
Loading
Loading