@hyodotdev/openiap-commerce-protocol 0.0.0-bootstrap.0 → 0.1.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.
Files changed (42) hide show
  1. package/CONVENTION.md +168 -0
  2. package/DESIGN.md +1056 -0
  3. package/README.md +227 -5
  4. package/SPEC.md +1471 -0
  5. package/conformance/index.d.ts +303 -0
  6. package/conformance/index.mjs +2126 -0
  7. package/conformance/mock-provider.mjs +491 -0
  8. package/examples/entitlement-granted-no-subscription.json +12 -0
  9. package/examples/entitlement-revoked.json +21 -0
  10. package/examples/provider-capabilities.json +209 -0
  11. package/examples/store-event-mapping.json +287 -0
  12. package/examples/subscription-canceled.json +22 -0
  13. package/examples/subscription-product-changed.json +30 -0
  14. package/examples/subscription-renewed.json +29 -0
  15. package/examples/verify-purchase-request.json +6 -0
  16. package/examples/verify-purchase-result.json +7 -0
  17. package/generated/bindings/graphql-operations.json +87 -0
  18. package/generated/bindings/http-binding.json +143 -0
  19. package/generated/bindings/introspection-signature.json +320 -0
  20. package/generated/bindings/operations-sdl.json +4 -0
  21. package/generated/bindings/operations.graphql +366 -0
  22. package/generated/commerce-protocol.graphql +1219 -0
  23. package/generated/openapi/commerce-protocol.openapi.json +1413 -0
  24. package/generated/schemas/commerce-event.schema.json +499 -0
  25. package/generated/schemas/commerce-protocol.bundle.schema.json +1576 -0
  26. package/generated/schemas/operations.schema.json +578 -0
  27. package/generated/schemas/primitives.schema.json +101 -0
  28. package/generated/schemas/provider-capabilities.schema.json +205 -0
  29. package/generated/schemas/store-event-mapping.schema.json +211 -0
  30. package/generated/vectors/lifecycle.json +908 -0
  31. package/generated/vectors/operations.json +1122 -0
  32. package/package.json +62 -12
  33. package/schema/01-primitives.graphql +102 -0
  34. package/schema/02-commerce-event.graphql +195 -0
  35. package/schema/03-provider-capabilities.graphql +139 -0
  36. package/schema/04-store-event-mapping.graphql +98 -0
  37. package/schema/05-operations.graphql +461 -0
  38. package/schema/06-compiler-vocabulary.graphql +139 -0
  39. package/schema/07-protocol-metadata.graphql +76 -0
  40. package/src/index.d.ts +63 -0
  41. package/src/index.mjs +121 -0
  42. package/vectors/signatures.json +139 -0
package/SPEC.md ADDED
@@ -0,0 +1,1471 @@
1
+ # OpenIAP Commerce Protocol Specification 1.0
2
+
3
+ A vendor-neutral contract for the **server side** of in-app purchases: one
4
+ normalized commerce vocabulary, one portable operation surface with REST and
5
+ GraphQL bindings, one event envelope, one webhook contract.
6
+
7
+ A consumer that implements this specification can process subscription starts,
8
+ renewals, subscription refunds, and entitlement changes from Apple, Google,
9
+ Meta Horizon, and Amazon **without parsing a single store-native payload** and
10
+ without knowing which backend produced the event. A developer backend written
11
+ against the operation surface can verify purchases, read entitlements, bind
12
+ purchases to its own users, and erase them — and later replace the provider
13
+ behind those calls without rewriting the integration.
14
+
15
+ For product composition, see the non-normative
16
+ [ecosystem guide](https://openiap.dev/commerce-protocol/ecosystem).
17
+ For a practical entry point, start with the
18
+ [integration walkthrough](https://openiap.dev/commerce-protocol/getting-started)
19
+ or the [provider implementation guide](https://openiap.dev/commerce-protocol/implementation).
20
+ [Whitepaper §5](https://openiap.dev/commerce-protocol/whitepaper) provides a non-normative
21
+ architecture and persistence blueprint. This specification and the authored
22
+ schema remain authoritative for the contract.
23
+
24
+ ## Why this exists
25
+
26
+ OpenIAP normalizes the client-side purchase API across stores. The server side
27
+ has the same fragmentation and no equivalent answer. Each store expresses
28
+ validation, renewal, cancellation, expiration, refund, revocation, entitlement,
29
+ and server notifications differently, so every backend, analytics pipeline, and
30
+ integration re-learns four protocols and re-derives the same semantics — usually
31
+ with subtly different answers.
32
+
33
+ This specification defines that shared server-side layer.
34
+
35
+ ## What this is not
36
+
37
+ - **Not a product.** It specifies a contract, not a paywall builder,
38
+ experimentation platform, CRM, or analytics engine. Those consume this; they
39
+ are not built inside it.
40
+ - **Not tied to one implementation.** IAPKit is the open-source reference
41
+ implementation, not the standard. Any backend may implement this
42
+ specification, in any language, without importing IAPKit.
43
+ - **Not a client API.** Client-side purchase flow is OpenIAP's domain. Where a
44
+ concept exists on both sides, this document says so explicitly.
45
+
46
+ Naming, since the two are easy to confuse: **OpenIAP Commerce Protocol** is
47
+ this standard. **IAPKit** is its open-source reference implementation and a
48
+ hosted service built on it. Package and artifact names follow the protocol;
49
+ IAPKit is not normative.
50
+
51
+ ## No central dependency
52
+
53
+ This specification is a contract between two parties. It does not place a third
54
+ one in the middle.
55
+
56
+ An emitter MUST be able to produce conformant events, and a consumer to verify
57
+ and process them, with **no network request to infrastructure operated by the
58
+ OpenIAP project**, at build or run time. Specifically:
59
+
60
+ - No OpenIAP account, registration, or issued identifier. Every identifier here
61
+ is assigned by the emitter.
62
+ - No OpenIAP-issued credential. The webhook secret is exchanged directly
63
+ between emitter and consumer; see §9.4.2.
64
+ - No commerce data reaches the OpenIAP project under any rule in this document.
65
+ - Validation runs offline: the bundle schema resolves no external reference.
66
+ - Conformance is demonstrated against this package's fixtures and vectors, not
67
+ by routing production traffic anywhere.
68
+
69
+ ## Conformance language
70
+
71
+ `MUST`, `MUST NOT`, `SHOULD`, and `MAY` are used as defined in RFC 2119.
72
+
73
+ Four roles are addressed:
74
+
75
+ | Role | Who | Obligation |
76
+ | ------------ | ----------------------------------------- | -------------------------------------------------------------------------- |
77
+ | **Provider** | A backend that serves protocol operations | Serve the declared profiles over at least one binding and pass conformance |
78
+ | **Caller** | Anything that invokes an operation | Honour the auth roles, error model, and forward-compatibility rules |
79
+ | **Emitter** | A backend that produces commerce events | Produce documents valid against the schemas and honour the transport rules |
80
+ | **Consumer** | Anything that receives them | Honour the verification, idempotency, and forward-compatibility rules |
81
+
82
+ A provider is usually also an emitter, and a caller usually also a consumer;
83
+ the roles stay separate because each pair carries different obligations.
84
+
85
+ ## Machine-readable artifacts
86
+
87
+ This document and the GraphQL contract together define the protocol. The
88
+ contract is authored as the layers in `schema/`, assembled into the single file
89
+ `commerce-protocol.graphql`; it is authoritative for wire structure, the
90
+ operation surface, member presence and nullability, and per-member definitions.
91
+ This document is authoritative for domain behavior, cross-field rules,
92
+ transport, and compatibility. The generated JSON Schema bundle is the
93
+ executable projection validators consume.
94
+
95
+ | Artifact | Purpose |
96
+ | -------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
97
+ | `schema/` | The authored GraphQL contract layers — edit these |
98
+ | `generated/commerce-protocol.graphql` | Generated single-file assembly of `schema/`; exported at `./commerce-protocol.graphql` |
99
+ | `generated/` | Compiler output. Ignore this directory when reviewing the authored contract |
100
+ | `generated/schemas/commerce-protocol.bundle.schema.json` | Self-contained validator; prefer it in validator integrations |
101
+ | `generated/schemas/*.schema.json` | Generated modular JSON Schema artifacts |
102
+ | `examples/` | Canonical documents that MUST validate |
103
+ | `examples/store-event-mapping.json` | How each store's own notification vocabulary maps onto these event types |
104
+ | `vectors/signatures.json` | Signature vectors every implementation MUST reproduce |
105
+ | `generated/vectors/lifecycle.json` | Generated entitlement, first-binding, and event-emission vectors implementations reproduce |
106
+ | `generated/bindings/http-binding.json` | Generated HTTP manifest: method, path, auth role, statuses, and schema pointer per operation |
107
+ | `generated/bindings/operations.graphql` | Generated executable GraphQL projection the GraphQL binding serves |
108
+ | `generated/bindings/graphql-operations.json` | Generated canonical full-selection GraphQL documents |
109
+ | `generated/openapi/commerce-protocol.openapi.json` | Generated OpenAPI 3.1 document for the REST binding |
110
+ | `generated/vectors/operations.json` | Generated operation conformance vectors |
111
+ | `conformance/` | The portable conformance runner and its independent mock provider |
112
+
113
+ ---
114
+
115
+ ## 1. Scope and architecture
116
+
117
+ ### 1.1 Architecture
118
+
119
+ The protocol fixes two boundaries around a backend, and the backend behind them
120
+ is replaceable.
121
+
122
+ ```text
123
+ Apple / Google / Meta / Amazon the stores
124
+
125
+ │ store-native notifications and APIs
126
+
127
+ ┌─────────────────────────────────────────────┐
128
+ │ A backend that implements this spec │ ← IAPKit, another provider,
129
+ │ │ or the adopter's
130
+ │ verify → normalize → lifecycle → entitle │ own, in any language
131
+ └─────────────────────────────────────────────┘
132
+ ▲ │
133
+ │ │ OpenIAP Commerce Protocol events (§9) → any consumer:
134
+ │ ▼ analytics, subscriber
135
+ │ data pipeline / CRM / analytics experience, data pipeline
136
+
137
+ │ operations (§4): verify, status, entitlements,
138
+ │ bind, erase, capabilities — over REST (§6) or GraphQL (§7)
139
+
140
+ developer backend / app
141
+ ```
142
+
143
+ Two things are specified, both replaceable behind a provider swap:
144
+
145
+ - **The operation surface (§4–§8).** A caller verifies purchases, reads
146
+ entitlements, binds purchases to its own users, and erases them, over either
147
+ transport binding, against whichever backend implements the protocol.
148
+ - **The event on the outbound arrow (§9)**, and — for the store notifications
149
+ named in §9.2 — the normalized meaning an emitter assigns before sending it.
150
+
151
+ How the backend receives and verifies store facts, which other store APIs it
152
+ calls, what it stores, and how it scales remain implementation. A caller written
153
+ against these two surfaces does not change when the backend behind them does.
154
+
155
+ ## 2. Core domain model
156
+
157
+ ### 2.1 Conventions
158
+
159
+ **Timestamps** are integer milliseconds since the Unix epoch, UTC. The single
160
+ exception is the transport signature timestamp, which is in **seconds** — see
161
+ §9.4.2. That inconsistency is inherited from the deployed 1.0 wire format and is
162
+ recorded rather than silently corrected.
163
+
164
+ **Money** is `{ currency, amountMicros, provenance }`. `amountMicros` is an
165
+ integer in millionths of one currency unit, so `1.99 USD` is `1990000`.
166
+ Currency is an uppercase ISO 4217 code. `amountMicros` is always a non-negative
167
+ magnitude; the event type supplies direction. On a refund event it still says
168
+ what the purchase cost, not how much the store returned (§14).
169
+
170
+ > An absent amount means **unknown**. It never means zero. A consumer that
171
+ > treats a missing amount as `0` will under-report revenue.
172
+
173
+ `provenance` says where the number came from, and the three values are not
174
+ interchangeable:
175
+
176
+ | Value | Meaning |
177
+ | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
178
+ | `store` | The store asserted this amount in a signed notification or an authoritative API response. Only this value is safe to treat as financially authoritative. |
179
+ | `catalog` | Resolved from the emitter's own product catalog because the store asserted nothing. It is the list price, not necessarily what the customer paid — promotions, regional pricing, and taxes can all differ. |
180
+ | `inferred` | Derived by the emitter from other fields. Usable for estimates, never for reconciliation. |
181
+
182
+ An emitter MUST NOT present a `catalog` or `inferred` amount as `store`. A
183
+ consumer doing revenue reconciliation SHOULD accept only `store`.
184
+
185
+ **Identifiers** are opaque strings. A consumer MUST NOT parse structure out of
186
+ one.
187
+
188
+ **Enumerations** are closed unless this document says otherwise. Four value
189
+ spaces are deliberately open — `environment` here, `store` below,
190
+ `cancellationReason` on the subscription snapshot, and `eventType`, which §12
191
+ grows in a MINOR version — and a consumer MUST tolerate a value it does not
192
+ recognise in any of them, and MUST NOT act on one it does not know.
193
+
194
+ **Store, not platform.** This specification keys on `store`, never on device
195
+ platform. One device platform can host several stores — an Android build can
196
+ ship against Google Play, Amazon Appstore, or Meta Horizon — so platform does
197
+ not identify the authority for a purchase.
198
+
199
+ The store value space is **open**. This version names `apple`, `google`,
200
+ `horizon`, and `amazon`, but the contract MUST NOT be read as closing the set:
201
+ an implementation observing commerce on another platform is free to emit it, and
202
+ a consumer MUST accept and preserve an unrecognised store opaquely rather than
203
+ reject the event. Which stores a given backend integrates is its own business —
204
+ the point is that no release of this document stands between an adopter and a
205
+ platform they need.
206
+
207
+ ### 2.2 The four things that are not the same
208
+
209
+ Conflating these is the most common server-side commerce bug. They are distinct,
210
+ and each answers a different question.
211
+
212
+ | Concept | Question it answers | Nature |
213
+ | ---------------- | -------------------------------------------- | ------------------------------------ |
214
+ | **Transaction** | What economic event did the store record? | An immutable fact |
215
+ | **Subscription** | What is the current arrangement? | Mutable state |
216
+ | **Event** | What changed, and when? | An immutable fact about a transition |
217
+ | **Entitlement** | What may this customer access **right now**? | A derived predicate |
218
+
219
+ A subscription has one **state** at a time. A **transition** between states
220
+ produces an **event**. **Entitlement** is computed from state and time, and is
221
+ neither of the other two.
222
+
223
+ #### Subscription state
224
+
225
+ `Active`, `InGracePeriod`, `InBillingRetry`, `Paused`, `Expired`, `Revoked`,
226
+ `Refunded`, `Unknown`.
227
+
228
+ `Unknown` means the emitter could not classify the subscription — typically
229
+ because it was bootstrapped from a receipt rather than from a lifecycle
230
+ notification. It does not mean the state machine is uncertain.
231
+
232
+ `cancellationReason` is an optional, advisory token. This version names
233
+ `UserCanceled`, `BillingError`, `PriceIncreaseDeclined`, `ProductUnavailable`,
234
+ `Refunded`, and `Other`, but the value space is open. A consumer MUST tolerate
235
+ an unrecognised token and MUST NOT use this field as a billing or reporting fact:
236
+ unlike `price`, it carries no provenance.
237
+
238
+ ### 2.3 Entitlement
239
+
240
+ Entitlement is carried as `subscription.active`, and where a `subscription`
241
+ member is present that is the field to read — never a re-derivation from
242
+ `state`. A store that keeps no canonical subscription record sends no snapshot;
243
+ there the `entitlement.*` event type itself carries the decision (§9.5).
244
+
245
+ **Entitlement is not derivable from `state` alone**, and this is where naive
246
+ implementations go wrong:
247
+
248
+ > A customer who cancels keeps access until the end of the period they already
249
+ > paid for. `subscription.canceled` means _auto-renew was turned off_. It does
250
+ > **not** mean access was revoked.
251
+
252
+ The normative predicate is:
253
+
254
+ | State | Entitled? |
255
+ | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
256
+ | `Active` | Yes, while `now < expiresAt` |
257
+ | `InGracePeriod` | Yes, while `now < expiresAt` — where `expiresAt` is the end of the grace window, not of the period that failed to renew |
258
+ | `InBillingRetry` | No — the store is retrying billing and access is suspended |
259
+ | `Paused` | No |
260
+ | `Expired`, `Revoked`, `Refunded` | No |
261
+ | `Unknown` | No |
262
+
263
+ When `expiresAt` is present, `now == expiresAt` is **not** entitled: the
264
+ boundary is exclusive. When it is **absent**, no deadline is known and the state
265
+ alone decides — an `Active` or `InGracePeriod` subscription with no `expiresAt`
266
+ is entitled.
267
+
268
+ An emitter MUST set `subscription.active` to the result of this predicate as
269
+ evaluated at `processedAt` — the moment it derived the event, not the moment the
270
+ store's fact occurred. The two differ only when a notification is processed
271
+ after the entitlement boundary has passed, and there the later answer is the
272
+ safe one: a grace window that has since closed must not be reported as still
273
+ granting access. A consumer MUST NOT recompute entitlement from `state` and
274
+ ignore `active`.
275
+
276
+ `active` is therefore a snapshot at `processedAt`, not a promise about the later
277
+ delivery instant. When applying `active: true`, a consumer MUST NOT grant access
278
+ beyond a present `expiresAt`; it either schedules that deadline or re-reads an
279
+ authoritative status before then. A delivery received at or after `expiresAt`
280
+ MUST NOT open the gate. An absent `expiresAt` carries no deadline, so a consumer
281
+ that requires freshness beyond the event stream needs an emitter-specific
282
+ status source.
283
+
284
+ > **Do not confuse this with purchase-validation verdicts.** A synchronous
285
+ > receipt-validation result answers "is this receipt currently valid?" and has
286
+ > its own vocabulary in which a cancelled purchase is invalid. That is a
287
+ > different axis from subscription lifecycle state, where a cancelled
288
+ > subscription is still entitled until it expires. The same English word means
289
+ > opposite things on the two surfaces; they MUST NOT be merged.
290
+
291
+ ### 2.4 Identity
292
+
293
+ `projectId` identifies the emitter-side **scope** — whatever boundary the
294
+ emitter organises commerce by. For a multi-tenant backend that is a tenant; for
295
+ a single company's own backend it may be one constant. It is not issued by any
296
+ registry, and an implementation MUST NOT be required to obtain one from a third
297
+ party. The member name is inherited from the deployed 1.0 wire format;
298
+ `applicationId` is an optional finer scope within it. Both are opaque.
299
+
300
+ `userId` is the app user the purchase is bound to, expressed in the identity
301
+ space **shared by the emitter and its consumer**. There is no global user
302
+ directory and no central identity resolution: the value only has to mean the
303
+ same thing to those two parties. It is absent when no binding exists, and this
304
+ specification defines no account-merge semantics — a purchase can be observed
305
+ before any user is known.
306
+
307
+ An `entitlement.*` event is an actionable access decision, so it MUST carry both
308
+ `userId` and `productId`. An emitter that has not bound a purchase to a user may
309
+ emit a lifecycle event, but MUST defer the entitlement decision until the
310
+ binding exists.
311
+
312
+ At first binding, the emitter MUST coalesce all unbound gate changes into the
313
+ gate's **current** value, evaluated at the binding event's `processedAt`. The
314
+ unbound baseline is not entitled: emit one `entitlement.granted` if the current
315
+ predicate is true, and emit no entitlement event if it is false. An emitter MUST
316
+ NOT replay an historical grant or revoke whose result is no longer current. A
317
+ grant uses the occurrence value §9.3 assigns to the latest transition or
318
+ observation that established the current gate. An emitter that retains no
319
+ attributable occurrence MAY defer the grant to its next store observation
320
+ rather than invent source facts; until that observation arrives the consumer
321
+ has no entitlement event, so an emitter SHOULD retain enough of the
322
+ originating occurrence to grant at binding. `processedAt` records when the
323
+ bound event was derived. `generated/vectors/lifecycle.json` pins the coalescing
324
+ cases, including a grant that expired before binding.
325
+
326
+ `transactionId` and `originalTransactionId` carry store-side transaction
327
+ identity **where the store exposes it**. Neither is universally available:
328
+ Google Play does not put one in a subscription notification — an emitter that
329
+ wants it must read the store's subscription API — and Meta
330
+ Horizon exposes no transaction identity at all. A consumer MUST NOT require
331
+ them.
332
+
333
+ ## 3. Profiles
334
+
335
+ The protocol is a small core plus named profiles, so a provider can be honest
336
+ about what it serves and a caller can branch on declarations instead of
337
+ guessing.
338
+
339
+ **Core** is not a profile; every provider carries it: the domain model (§2),
340
+ the portable error model (§8), the capability descriptor and its
341
+ `providerCapabilities` operation (§10), and the versioning rules (§12).
342
+
343
+ | Profile | Operations | Obligations |
344
+ | ------------------ | ------------------------------------ | -------------------------------------------------------------------------------------------- |
345
+ | `verification` | `verifyPurchase` | Verify store evidence under §4.1 without touching any account |
346
+ | `entitlements` | `subscriptionStatus`, `entitlements` | Serve tokenless, fail-close server reads under §4.2 and §4.3 |
347
+ | `events` | none | Emit and deliver normalized events under §9 — taxonomy, envelope, signature, and retry rules |
348
+ | `accountLifecycle` | `bindPurchase`, `eraseUser` | Bind purchases to caller-owned identities and erase them under §4.4 and §4.5 |
349
+
350
+ Profile membership is declared in the SDL and generated into the HTTP
351
+ manifest; the four names above are this version's, and the space is open —
352
+ a consumer MUST ignore a profile name it does not recognise.
353
+
354
+ A provider implements a profile **completely or not at all**. It MUST declare
355
+ in its capability descriptor (§10) every profile it serves and MUST NOT
356
+ declare one it serves partially or does not pass conformance for (§11).
357
+ Profiles version independently as MAJOR.MINOR; a caller pins on the major.
358
+
359
+ ---
360
+
361
+ ## 4. Operations
362
+
363
+ Six operations make up the 1.0 surface. `commerce-protocol.graphql` is
364
+ authoritative for their input and result structure; this section is
365
+ authoritative for their behavior. Rules that apply to every operation:
366
+
367
+ - **One input, one result.** An operation takes at most one `input` document
368
+ and returns one result document or one protocol error (§8).
369
+ - **Omission means unknown.** A provider that cannot determine an optional
370
+ result member MUST omit it, never send a placeholder — the same rule §12
371
+ states for events. Operation types never combine nullable with omittable,
372
+ so the two bindings cannot disagree about what an absent member means.
373
+ - **Idempotency.** Every 1.0 operation is idempotent: repeating a call with
374
+ the same input yields the same outcome, apart from members the operation
375
+ documents as progressing (an erasure job's `status`). A caller MAY retry on
376
+ timeout without a dedicated idempotency key.
377
+ - **Fail-close.** A provider that cannot answer completely — a partial read,
378
+ an overflowing record set, an unclassifiable state — MUST fail the
379
+ operation rather than answer from what it has.
380
+ - **Provider time.** Entitlement gates are evaluated at the provider's own
381
+ read time. A caller-supplied timestamp is never an input to an access
382
+ decision.
383
+ - **Unknown input members.** The REST binding ignores an input member it does
384
+ not recognise, which is what makes a MINOR input addition safe (§6). The
385
+ GraphQL binding rejects one at validation instead (§7); GraphQL callers
386
+ regenerate against the published schema.
387
+
388
+ ### 4.1 verifyPurchase
389
+
390
+ Verifies store evidence and returns a verdict. The evidence union is
391
+ discriminated on the open `store` space: for each store this version names,
392
+ the matching evidence member is required, and a provider MUST reject a store
393
+ it does not integrate with `UNSUPPORTED_STORE` rather than failing schema
394
+ validation — a future store is a MINOR addition, not a break.
395
+
396
+ `isValid` is the acceptance gate and the only one: `state` is advisory detail
397
+ in an open token space, and a caller MUST NOT re-derive acceptance from it.
398
+ Store rejection of the evidence is a **successful** operation whose result
399
+ says `isValid: false`; `VERIFICATION_FAILED` is reserved for the provider
400
+ failing to obtain a verdict at all. A verification verdict is the
401
+ purchase-validation axis §2.3 warns about — it is not subscription lifecycle
402
+ state.
403
+
404
+ Verification is account-free. It binds no user, reads no account, and is
405
+ callable with the verification role (§5), so a shipped app can hold the
406
+ credential that calls it. A provider MUST NOT let this operation, or any
407
+ input to it, select or mutate account state.
408
+
409
+ ### 4.2 subscriptionStatus
410
+
411
+ A developer backend reads one user's subscription standing: an `active` gate
412
+ for the user as a whole, plus the most relevant record — the current
413
+ entitling subscription when one exists, otherwise the provider's most recent
414
+ record as context, and no record member at all when the provider has none.
415
+
416
+ The snapshot is **tokenless by construction**: no purchase token, store
417
+ transaction identity, signed receipt, or provider-internal record identifier
418
+ appears in it. Server role only — a verification credential MUST be refused,
419
+ because with it a shipped app could walk arbitrary user identities.
420
+
421
+ ### 4.3 entitlements
422
+
423
+ The access decision for one user: every product whose gate is open at the
424
+ provider's read time, with the entitling records. Unknown, expired, and
425
+ ambiguous records contribute nothing. The same tokenless and server-role
426
+ rules as §4.2 apply.
427
+
428
+ ### 4.4 bindPurchase
429
+
430
+ Connects verified store evidence to the caller's own opaque user identity —
431
+ the identity space of §2.4. Server role only: token possession is
432
+ deliberately not proof of ownership, so binding is a decision the caller's
433
+ authenticated backend makes, never a shipped app.
434
+
435
+ Binding is idempotent and never moves an existing binding. `bound: false`
436
+ covers every non-binding outcome — unknown evidence, evidence bound to a
437
+ different user, a store the provider cannot bind — without distinguishing
438
+ them, so the operation cannot probe whether someone else's purchase exists.
439
+ How a provider recovers a purchase bound to the wrong user is management
440
+ plane, outside this contract.
441
+
442
+ ### 4.5 eraseUser
443
+
444
+ Removes a user identity from the provider's subscription records and from its
445
+ protocol event identity. Server role only. The operation acknowledges with
446
+ `accepted` and, where the provider processes erasure as a job, a `jobId` and
447
+ an open-space `status`; re-requesting the same user is idempotent and reports
448
+ the current job.
449
+
450
+ Erasure is bounded by physics: a provider erases **its own** records and
451
+ event store. It CANNOT unsend events, so copies already delivered to the
452
+ caller's systems are the caller's own responsibility to erase — a provider
453
+ MUST NOT claim otherwise.
454
+
455
+ ### 4.6 providerCapabilities
456
+
457
+ Returns the capability descriptor (§10). No credential, no commerce data,
458
+ and no central registry: the answer is self-describing and a conformance
459
+ runner reads it the same way a caller does.
460
+
461
+ ---
462
+
463
+ ## 5. Authentication and trust
464
+
465
+ The protocol standardizes **roles and rules**, not credential formats. How a
466
+ provider issues, names, or rotates credentials is its own business; no
467
+ prefix, length, or issuer is part of this contract.
468
+
469
+ | Role | Holder | May call |
470
+ | ---------------- | ---------------------------------- | ---------------------------------------------------- |
471
+ | **verification** | May ship inside an application | `verifyPurchase`, `providerCapabilities` |
472
+ | **server** | The caller's authenticated backend | Everything the verification role may, plus §4.2–§4.5 |
473
+ | _operator_ | Whoever administers the provider | Management plane — outside the portable contract |
474
+
475
+ Both bindings MUST enforce:
476
+
477
+ - Credentials travel in the `Authorization` header. A provider MUST NOT
478
+ accept a secret in a URL path or query string, where proxies and logs
479
+ retain it.
480
+ - Auth failures fail close: no credential is `UNAUTHORIZED`, a credential of
481
+ the wrong role is `FORBIDDEN`, and neither response reveals whether the
482
+ target of the call exists.
483
+ - For an operation that requires the **server** role, authorization precedes
484
+ input validation: a caller without a valid server credential MUST receive
485
+ `UNAUTHORIZED` or `FORBIDDEN`, never a verdict about its input — an
486
+ input-validation answer would let an unauthenticated caller map the
487
+ privileged surface (which stores bind, which members exist, which bounds
488
+ apply). Transport-shape failures — an unparseable or oversized body, or a
489
+ GraphQL document that fails parsing or validation — MAY still precede
490
+ authorization: they say nothing operation-specific. Variable coercion
491
+ against the operation input IS input validation, not transport shape — a
492
+ GraphQL engine coerces variables before any resolver runs, so a provider
493
+ that authorizes only inside resolvers violates this rule and MUST
494
+ authorize the operation before executing the document. Verification-role
495
+ operations are exempt
496
+ because their input schema is the published client contract an application
497
+ already ships with.
498
+ - The verification role and the server role are distinct credentials. A
499
+ provider MUST NOT let a verification credential reach an account read or
500
+ mutation, which is what blocks arbitrary-`userId` lookups from shipped
501
+ apps.
502
+ - A provider MAY rate-limit any operation. It signals with `RATE_LIMITED`
503
+ and SHOULD send `Retry-After` seconds on the REST binding.
504
+ - 1.0 defines no pagination: no operation returns an unbounded collection,
505
+ and §4's fail-close rule covers a record set a provider cannot bound. A
506
+ future paginated operation defines its cursor semantics when it is added.
507
+
508
+ ---
509
+
510
+ ## 6. REST binding
511
+
512
+ The REST binding serves every operation under the versioned `/commerce/v1`
513
+ namespace, described end to end by two generated artifacts: the HTTP manifest
514
+ (`generated/bindings/http-binding.json`) and the OpenAPI 3.1 document. Both
515
+ are compiled from the SDL — neither is authored, so neither can drift from
516
+ the contract.
517
+
518
+ Per operation the manifest fixes: HTTP method (`GET` for queries, `POST` for
519
+ mutations), path, auth role, success status, idempotency, the error codes it
520
+ may return, and JSON Schema pointers for its input and result inside the
521
+ offline bundle.
522
+
523
+ - A `GET` operation carries its input as query parameters; every such input
524
+ member is a scalar by construction (the compiler rejects anything else).
525
+ Opaque identifiers are not secrets (§5 keeps credentials out of URLs), but
526
+ a deployment that must keep user identifiers out of intermediary logs
527
+ should note that they ride the query string here.
528
+ - A `POST` operation carries its input as a JSON body with
529
+ `Content-Type: application/json`.
530
+ - Success is exactly the operation's `successStatus`. Every failure returns
531
+ the status §8 assigns to its code, with a `ProtocolErrorResponse` body.
532
+ - An unrecognised input member is ignored (§4), and a caller MUST ignore
533
+ unrecognised result members — the same open-object rule the event envelope
534
+ follows.
535
+
536
+ ### 6.1 Default paths
537
+
538
+ | Operation | Method | Path |
539
+ | ---------------------- | ------ | ----------------------------------- |
540
+ | `providerCapabilities` | GET | `/commerce/v1/capabilities` |
541
+ | `subscriptionStatus` | GET | `/commerce/v1/subscriptions/status` |
542
+ | `entitlements` | GET | `/commerce/v1/entitlements` |
543
+ | `verifyPurchase` | POST | `/commerce/v1/purchases/verify` |
544
+ | `bindPurchase` | POST | `/commerce/v1/purchases/bind` |
545
+ | `eraseUser` | POST | `/commerce/v1/users/erase` |
546
+
547
+ A provider serves these paths relative to a base URL it documents. The path
548
+ segment `v1` is the protocol major version, so a future major can be served
549
+ beside this one.
550
+
551
+ ---
552
+
553
+ ## 7. GraphQL binding
554
+
555
+ The GraphQL binding serves the same six operations at one HTTP endpoint —
556
+ IAPKit serves `/commerce/v1/graphql`, and a provider documents its own — as
557
+ an executable schema that MUST define everything the generated projection
558
+ (`generated/bindings/operations.graphql`) defines, exactly as it defines it;
559
+ a newer compatible MINOR may extend it additively (§12), never alter it.
560
+ Introspection, where enabled, MUST agree with the schema served; the
561
+ projection contains no secret, so there is no reason to hide it, and a
562
+ provider MAY still gate introspection behind a credential.
563
+
564
+ - Requests are `POST` with the standard `{query, operationName, variables}`
565
+ JSON body, the operation input passed as the `input` variable. The same
566
+ credentials travel in the same `Authorization` header, and the same role
567
+ rules apply (§5).
568
+ - **No Subscription root, ever.** The operation surface is bounded
569
+ request/response; a GraphQL subscription is a stream a shipped app could
570
+ hold open, which the webhook direction rule (§9.4) forbids. The compiler
571
+ rejects a `Subscription` type in the SDL.
572
+ - An operation failure is an HTTP `200` whose `errors[*].extensions.code`
573
+ carries the §8 code — any error that carries a protocol code MUST be
574
+ delivered at `200`. This includes a refusal decided before execution,
575
+ such as an authorization or rate-limit rejection; a pre-execution refusal
576
+ omits the `data` member.
577
+ - A request-level failure — the document or variables themselves could not
578
+ be processed: unparseable document, validation failure, variable coercion
579
+ — MAY carry no protocol code or MAY carry the generic `INVALID_REQUEST`,
580
+ never a more specific code. The two categories are exclusive per envelope:
581
+ one `errors` array is either all coded or all codeless — a codeless entry
582
+ riding beside coded ones would be invisible to every code check. It omits the `data` member entirely, and only
583
+ the codeless form MAY be delivered as HTTP `400` instead of `200`. A
584
+ caller treats either form as `INVALID_REQUEST`; only where the request
585
+ died differs.
586
+ - GraphQL cannot express omitted-versus-null on a selected member: a member
587
+ the provider omitted comes back as `null`. Operation types therefore never
588
+ make `null` meaningful (the compiler rejects a nullable omittable member),
589
+ and a caller normalizes `null` to absent. On input, an explicit `null` for
590
+ an omittable member means absent.
591
+ - A provider MAY bound query depth, size, or aliasing, but MUST accept the
592
+ canonical documents (`generated/bindings/graphql-operations.json`) — they
593
+ are the deepest selections the contract can produce.
594
+ - Business logic MUST NOT live in resolvers. Resolvers adapt transport;
595
+ §11's parity requirement exists to make a divergent resolver visible.
596
+
597
+ ---
598
+
599
+ ## 8. Portable errors
600
+
601
+ One open code space serves both bindings; the wire wrapper differs, the
602
+ meaning MUST NOT. REST wraps a failure as
603
+ `{ "error": { "code", "message" } }` with the mapped status; GraphQL carries
604
+ the code in `errors[*].extensions.code` (§7). A message is human-readable
605
+ and MUST NOT contain credentials, store evidence, signed payloads, stack
606
+ traces, or implementation source paths.
607
+
608
+ | Code | HTTP | Meaning |
609
+ | --------------------- | ---- | ---------------------------------------------------------------------------- |
610
+ | `INVALID_REQUEST` | 400 | The input is malformed or fails the operation schema |
611
+ | `UNAUTHORIZED` | 401 | No usable credential was presented |
612
+ | `FORBIDDEN` | 403 | The credential's role may not call this operation |
613
+ | `NOT_FOUND` | 404 | The addressed resource does not exist |
614
+ | `PURCHASE_NOT_FOUND` | 404 | The evidenced purchase is unknown, where an operation distinguishes that |
615
+ | `CONFLICT` | 409 | The request contradicts current state |
616
+ | `UNSUPPORTED_STORE` | 422 | The provider does not integrate the named store |
617
+ | `RATE_LIMITED` | 429 | Too many requests; retry after the signalled delay |
618
+ | `INTERNAL_ERROR` | 500 | The provider failed internally |
619
+ | `UNSUPPORTED_PROFILE` | 501 | The operation belongs to a profile this provider does not serve |
620
+ | `VERIFICATION_FAILED` | 502 | The provider could not obtain a verdict — never the store rejecting evidence |
621
+
622
+ The space is open: a MINOR version can add a code, so a caller MUST treat an
623
+ unrecognised code as a failure of the operation rather than a protocol
624
+ violation. The generated manifest carries this same table as
625
+ `errorStatus`; the test suite keeps the two in exact agreement.
626
+
627
+ ---
628
+
629
+ ## 9. Event delivery
630
+
631
+ The asynchronous half of the protocol: what an emitter says happened, and
632
+ how it reaches a consumer. Everything in this section is the Event
633
+ Delivery profile (§3); its rules bind any implementation that declares
634
+ `events`, whether or not it serves the operation surface.
635
+
636
+ ### 9.1 Event taxonomy
637
+
638
+ Two families of event type, listed in full below and named as examples in the
639
+ generated event schema. The value space remains open so a MINOR version can add
640
+ a type. The known taxonomy is deliberately small: it covers exactly the
641
+ transitions at least one store can actually report, and nothing speculative.
642
+
643
+ ### Subscription lifecycle
644
+
645
+ | Event | Meaning |
646
+ | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
647
+ | `subscription.started` | The emitter began a new subscription record, including a resubscription for which it has no earlier store history |
648
+ | `subscription.renewed` | A billing period completed and another began |
649
+ | `subscription.recovered` | An existing subscription record is live again — billing succeeded after a failure, a customer resubscribed, or a refund was reversed. A consumer measuring dunning recovery specifically MUST NOT count this event alone; §9.2 says which store notification produced it |
650
+ | `subscription.entered_grace_period` | Billing failed; access retained while the store retries |
651
+ | `subscription.entered_billing_retry` | Billing failed; access suspended while the store retries |
652
+ | `subscription.expired` | The subscription ended |
653
+ | `subscription.canceled` | Auto-renew was turned off; access continues until the period ends |
654
+ | `subscription.uncanceled` | Auto-renew was turned back on before the period ended |
655
+ | `subscription.revoked` | The store withdrew the purchase |
656
+ | `subscription.refunded` | The purchase was refunded |
657
+ | `subscription.product_changed` | The subscription moved to a different product |
658
+ | `subscription.price_changed` | The renewal price changed |
659
+ | `subscription.deferred` | The next billing date was pushed out without a product change |
660
+ | `subscription.paused` | The subscription was paused |
661
+ | `subscription.resumed` | A paused subscription resumed |
662
+
663
+ The following maps each lifecycle event to the snapshot state it names, as the
664
+ GraphQL contract enforces it:
665
+
666
+ ```mermaid
667
+ flowchart LR
668
+ subgraph lands ["names a state — when a snapshot is present, its state MUST agree"]
669
+ direction LR
670
+ e1["started<br/>renewed<br/>recovered<br/>resumed"] --> Active
671
+ e2["entered_grace_period"] --> InGracePeriod
672
+ e3["entered_billing_retry"] --> InBillingRetry
673
+ e4["paused"] --> Paused
674
+ e5["expired"] --> Expired
675
+ e6["revoked"] --> Revoked
676
+ e7["refunded"] --> Refunded
677
+ end
678
+ subgraph none ["names no state — the contract pairs no state with these, so a snapshot keeps the state that actually followed the store transition"]
679
+ direction LR
680
+ e8["canceled · uncanceled<br/>product_changed · price_changed · deferred"]
681
+ end
682
+ subgraph predicate ["entitled? (§2.3) — read subscription.active; a consumer MUST NOT recompute it from state and ignore active"]
683
+ direction LR
684
+ p1["Active, InGracePeriod: yes while now is before expiresAt; with no expiresAt, yes.<br/>For InGracePeriod, expiresAt is the end of the grace window."]
685
+ p2["InBillingRetry, Paused, Expired, Revoked, Refunded, Unknown: no"]
686
+ end
687
+ ```
688
+
689
+ ### Entitlement delta
690
+
691
+ | Event | Meaning |
692
+ | --------------------- | ----------------------- |
693
+ | `entitlement.granted` | Access became available |
694
+ | `entitlement.revoked` | Access was withdrawn |
695
+
696
+ An emitter MUST emit an entitlement event **only when the gate actually flips** —
697
+ that is, when the entitlement predicate's result differs from its result before
698
+ the transition. Emitting one alongside every lifecycle event would make the
699
+ signal useless for access control.
700
+
701
+ An entitlement event carries `userId` and `productId` (§2.4). If it includes a
702
+ subscription snapshot, `entitlement.granted` requires `active: true` and
703
+ `entitlement.revoked` requires `active: false`.
704
+
705
+ When a subscription snapshot is present, its state MUST agree with lifecycle
706
+ events that name a state: `started`, `renewed`, `recovered`, and `resumed` use
707
+ `Active`; `entered_grace_period` uses `InGracePeriod`;
708
+ `entered_billing_retry` uses `InBillingRetry`; and `expired`, `revoked`,
709
+ `refunded`, and `paused` use their corresponding states. The GraphQL contract
710
+ enforces these pairs. Events such as cancellation and product or price changes
711
+ do not name a state and therefore retain the state that actually followed the
712
+ store transition.
713
+
714
+ A transition that changes nothing — a redelivered notification, a no-op update —
715
+ MUST emit no event at all. Otherwise consumers count retries as activity.
716
+
717
+ ### 9.2 Where the events come from
718
+
719
+ `examples/store-event-mapping.json` gives the normalization table: each store's
720
+ own notification type, and the event it becomes. An implementer follows it
721
+ instead of reverse-engineering an existing backend.
722
+
723
+ Four properties of that table are worth stating here, because they are easy to
724
+ get wrong:
725
+
726
+ - **A notification can map to nothing.** An audit-only or informational
727
+ notification — a delivery test, a pause-schedule metadata update, a consent
728
+ change — is received, acknowledged, and emits no event. That is a mapping, not
729
+ a gap, and each such row carries its reason.
730
+ - **One notification type usually means more than one thing.** Three kinds of
731
+ qualifier separate them, and a row carries whichever applies:
732
+ - a **subtype** the store sends — Apple marks a recovery from billing failure
733
+ with `BILLING_RECOVERY` on the same `DID_RENEW` it uses for an ordinary
734
+ renewal;
735
+ - the subscription's **prior state** — Google sends `SUBSCRIPTION_RECOVERED`
736
+ for both a recovery and a resume from pause and marks nothing, so only the
737
+ state before the event separates them (`whenPreviousState`);
738
+ - whether the emitter has **any store history** for the purchase
739
+ (`whenNoPriorStoreEvent`). This one is easy to miss and changes the answer
740
+ often: a notification about a purchase the emitter has never heard from the
741
+ store before begins the story, while the identical notification about a
742
+ purchase it has been tracking continues one. Note the axis is store history,
743
+ not record existence — a purchase learned from a client receipt but never
744
+ from the store still begins the story.
745
+
746
+ An implementation MUST prefer a qualified row over the unconditional one. A
747
+ row never carries both `whenNoPriorStoreEvent` and `whenPreviousState`; the
748
+ first says there is no history to have a state in, the second says what that
749
+ state was.
750
+
751
+ Selection is deterministic. A row's wire key is `storeNotificationCode` when
752
+ present, otherwise `storeNotification`; first retain rows whose key equals the
753
+ received notification value. Then prefer an exact `storeSubtype`. If no exact
754
+ subtype row exists, use the row whose subtype is absent or null. Within that
755
+ pair, prefer a matching history or prior-state condition over the
756
+ unconditional row. If no row matches, acknowledge the notification and emit
757
+ nothing; an emitter MUST NOT guess a lifecycle event.
758
+
759
+ - **The store's own wire value is `storeNotificationCode` where it differs from
760
+ the name.** Google Play transmits a number; `SUBSCRIPTION_RENEWED` is a
761
+ documentation label that never appears on the wire. Apple transmits the name.
762
+ - **Entitlement events are never mapped.** They are derived from the gate
763
+ flipping, so no store notification produces one directly.
764
+
765
+ ### 9.3 The event envelope
766
+
767
+ Full structure in `commerce-protocol.graphql`; its generated validator is
768
+ `generated/schemas/commerce-event.schema.json`. Required members:
769
+ `eventId`, `eventType`, `eventVersion`, `occurredAt`, `processedAt`, `store`,
770
+ `environment`, `projectId`. An entitlement event additionally requires `userId`
771
+ and `productId` (§2.4).
772
+
773
+ `occurredAt` is the best authoritative time for the commerce fact. For a store
774
+ notification or API response that supplies the transition time, it is that
775
+ store-asserted time. If a poll reveals only that a value changed since the last
776
+ observation, it is the time the emitter observed the new value; an emitter MUST
777
+ NOT invent or interpolate a more precise instant. That fallback is an
778
+ observation boundary, not a claim about the exact store transition.
779
+
780
+ `processedAt` is when the emitter derived the event. It can differ from
781
+ `occurredAt` by hours after an outage. Consumers use `occurredAt` for the
782
+ portable business ordering described in §9.4.4, while recognising that a
783
+ poll-derived value orders observations rather than reconstructing an unknown
784
+ instant inside the polling interval.
785
+
786
+ **`price` is context, not a charge record, and events are not summable.** Stores
787
+ repeat the subscription's amount on notification after notification: one Apple
788
+ subscription that renews, is cancelled, then lapses produces three events all
789
+ carrying the same figure. Within a single notification the amount also rides
790
+ exactly one event — the lifecycle event where there is one, otherwise the
791
+ entitlement event — so it is never duplicated by the entitlement delta. But
792
+ across notifications it recurs by design.
793
+
794
+ A consumer computing revenue therefore MUST NOT add up every event that has a
795
+ `price`. `subscription.started`, `subscription.renewed`, and
796
+ `subscription.recovered` can represent positive revenue, but a consumer SHOULD
797
+ book them only when `provenance` is `store`. `subscription.refunded` says a
798
+ reversal occurred, but 1.0 carries no returned amount, so a consumer MUST NOT
799
+ debit `price.amountMicros` as though it were the refund amount. It must reconcile
800
+ the amount through store-authoritative data. Every other event carries `price`
801
+ only as context. `subscription.revoked` is not a reversal: it withdraws access,
802
+ and whether money moved with it is a fact the stores do not always report.
803
+
804
+ `eventId` is the deduplication key, and the only one. A `transactionId` cannot
805
+ serve as a second: Play subscription notifications carry none at all, and where a
806
+ store does issue one it repeats it across the charge, the refund that reverses
807
+ it, and the re-charge that reverses the refund — which a reversal of an
808
+ already-recovered charge collapses onto one event type as well. §9.1 already
809
+ requires an emitter not to re-emit an unchanged fact, so a second key defends
810
+ against a violation of that rule rather than against anything this contract
811
+ permits.
812
+
813
+ `sourceStoreEventId` carries the **store's own** notification identifier — an
814
+ App Store `notificationUUID`, a Play RTDN `messageId` — so support can
815
+ cross-reference against the store console. It is not the emitter's own row
816
+ identifier, and an emitter MUST NOT place internal record identifiers in it.
817
+
818
+ `extensions` is the escape hatch for store-specific detail with no canonical
819
+ equivalent. It is flat, string-valued, and bounded at 24 entries, 64-character
820
+ keys, and 512-character values. Its content is provider-influenced: a consumer
821
+ MUST treat every value as untrusted input.
822
+
823
+ An emitter MUST NOT place credentials, raw store payloads, signed receipts, or
824
+ personal data beyond the identifiers above into any member.
825
+
826
+ ### 9.4 Webhook contract
827
+
828
+ Direction is **store → backend → consumer**, server-to-server in both hops.
829
+
830
+ This specification defines **no** backend-to-client stream. There is no SSE
831
+ endpoint, WebSocket, push relay, or long-poll feed for shipped applications: a
832
+ project-wide event feed and its signing secret must never reach a distributed
833
+ app. An application that needs device push gets it from its own authenticated
834
+ backend, downstream of this contract.
835
+
836
+ The following sequence summarizes one delivery under §9.4.1–9.4.4, which state
837
+ each step normatively:
838
+
839
+ ```mermaid
840
+ sequenceDiagram
841
+ participant Store
842
+ participant Emitter as Backend (emitter)
843
+ participant Consumer as Consumer endpoint
844
+
845
+ Note over Emitter,Consumer: Delivery is duplicate-capable and unordered, with no exactly-once guarantee (§9.4.4). This is one attempt.
846
+
847
+ Store->>Emitter: store-native notification
848
+ Emitter->>Emitter: normalize (§9.2), then derive the lifecycle and entitlement events (§9.1)
849
+ Emitter->>Consumer: POST to the consumer's HTTPS URL — Content-Type: application/json, Content-Encoding absent or identity<br/>body: one event, the exact UTF-8 bytes<br/>openiap-signature: v1=lowercase hex (several, comma-separated, during rotation)<br/>openiap-timestamp: exactly one, Unix seconds, base-10, no sign<br/>openiap-event-id · openiap-delivery-id
850
+ Note over Consumer: MUST: reject if abs(now − timestamp) > 300 s<br/>MUST: signed = ascii(timestamp) + "." + the exact body bytes received, then HMAC-SHA256 with the shared secret<br/>MUST: split openiap-signature on "," and trim — accept if any presented v1= matches any valid secret, compared in constant time<br/>MUST: take eventId from the parsed body, not the header — be idempotent on it<br/>SHOULD: acknowledge before slow downstream work
851
+ alt 2xx
852
+ Consumer-->>Emitter: 2xx
853
+ Note over Emitter: delivered
854
+ else 408, 429, 5xx — or no response (timeout, connection error)
855
+ Consumer-->>Emitter: 408 / 429 / 5xx, or nothing
856
+ Note over Emitter: retry with exponential backoff: same body and eventId, fresh openiap-timestamp,<br/>recomputed signature, same openiap-delivery-id — eventually stop and dead-letter
857
+ else 3xx, or any other 4xx
858
+ Consumer-->>Emitter: 3xx / other 4xx
859
+ Note over Emitter: permanent failure — a redirect is not followed, nothing is retried
860
+ end
861
+ ```
862
+
863
+ #### 9.4.1 Request
864
+
865
+ `POST` to an HTTPS URL the consumer gave the emitter directly. The body is one
866
+ event document encoded as UTF-8, with `Content-Type: application/json`.
867
+ `Content-Encoding` MUST be absent or `identity`; transport compression would
868
+ make “raw body bytes” ambiguous across HTTP stacks.
869
+
870
+ | Header | Value |
871
+ | --------------------- | ------------------------------------------------------ |
872
+ | `openiap-signature` | `v1=<hex>`, or several comma-separated during rotation |
873
+ | `openiap-timestamp` | Unix **seconds** at signing |
874
+ | `openiap-event-id` | The event's `eventId` |
875
+ | `openiap-delivery-id` | Identifies this delivery attempt chain |
876
+
877
+ The headers are conveniences. **The signed body is the authority**: a consumer
878
+ MUST take `eventId` from the parsed body, not from the header, because only the
879
+ body is covered by the signature. A request MUST carry exactly one
880
+ `openiap-timestamp`, encoded as a non-negative base-10 integer with no sign.
881
+
882
+ #### 9.4.2 Signature
883
+
884
+ ```text
885
+ signed_payload = ascii(openiap-timestamp) || 0x2e || raw_body_bytes
886
+ signature = "v1=" + lowercase_hex(HMAC_SHA256(secret_bytes, signed_payload))
887
+ ```
888
+
889
+ `ascii(openiap-timestamp)` is the exact base-10 header value, `0x2e` is `.`, and
890
+ `raw_body_bytes` are the **exact bytes received**. A consumer MUST NOT parse and
891
+ re-serialize the JSON before verifying: whitespace, escaping, key order, and
892
+ UTF-8 bytes are part of the signature.
893
+
894
+ The shared secret is an opaque string. Its exact UTF-8 bytes — including any
895
+ prefix — are the HMAC key; an implementation MUST NOT strip a prefix or decode a
896
+ hex-looking suffix. An emitter generating a secret MUST use a cryptographically
897
+ secure random source with at least 32 random bytes before encoding it.
898
+
899
+ A consumer MUST:
900
+
901
+ 1. Reject when `|now - timestamp| > 300` seconds. The timestamp is inside the
902
+ signed material, so a captured body cannot be replayed under a fresh header.
903
+ 2. Split `openiap-signature` on `,` and trim each value. Compare every presented
904
+ `v1=` signature against every currently valid secret, and accept if **any
905
+ pair** matches. Comparing the header as a whole fails during rotation.
906
+ 3. Compare in constant time.
907
+
908
+ During secret rotation an emitter signs with both keys and sends both values, so
909
+ a consumer that has rolled only one side still validates. An emitter SHOULD keep
910
+ the previous secret valid for at least 24 hours.
911
+
912
+ > This scheme carries no key identifier, so a consumer holding two signatures
913
+ > cannot tell which key produced which. Algorithm agility therefore requires a
914
+ > new signature prefix; `v1=` is the version marker.
915
+
916
+ `vectors/signatures.json` contains reproducible cases and rejection cases. An
917
+ implementation MUST reproduce every `expected` value and MUST reject every entry
918
+ in `rejections`. Its deterministic fixture secrets are test inputs, not examples
919
+ of production secret generation.
920
+
921
+ #### 9.4.3 Response semantics
922
+
923
+ | Consumer returns | Emitter behaviour |
924
+ | --------------------------- | -------------------------------- |
925
+ | `2xx` | Delivered |
926
+ | `408`, `429`, `5xx` | Retry |
927
+ | `3xx` | Permanent failure; do not follow |
928
+ | Other `4xx` | Permanent failure; do not retry |
929
+ | Timeout or connection error | Retry |
930
+
931
+ A consumer SHOULD acknowledge **before** doing slow downstream work. Holding the
932
+ connection open for processing invites duplicate deliveries.
933
+
934
+ Every retry is a new HTTP attempt. The emitter MUST keep the body and `eventId`
935
+ unchanged, choose a fresh current `openiap-timestamp`, and recompute the
936
+ signature. Replaying the original signed request after five minutes is not a
937
+ retry: a conforming consumer rejects it as stale. `openiap-delivery-id` stays
938
+ stable across the attempt chain.
939
+
940
+ #### 9.4.4 Delivery guarantees
941
+
942
+ **Duplicate-capable. Unordered. No exactly-once guarantee.**
943
+
944
+ An event may reach a consumer more than once. Eventual acceptance is not
945
+ guaranteed: a permanent response or exhausted retry budget ends in failure or a
946
+ dead-letter record, so a consumer may accept zero copies.
947
+
948
+ An emitter MUST retry with exponential backoff and MUST eventually stop and
949
+ dead-letter rather than retry forever.
950
+
951
+ A consumer MUST be idempotent on `eventId`, which is stable for the lifetime of
952
+ an event. An emitter MUST NOT reuse an `eventId` and MUST NOT change the
953
+ `eventId` of an event it has already delivered.
954
+
955
+ This specification provides **no ordering guarantee**. Retries, backoff, and
956
+ independent per-destination queues all reorder events. A consumer that needs the
957
+ business timeline uses `occurredAt`, not `processedAt`, and MUST tolerate an
958
+ older event arriving after a newer one. When it can correlate a stable purchase,
959
+ a stateful consumer MUST NOT let an older snapshot overwrite newer state. That
960
+ rule does not discard the whole event: independent idempotent effects, such as
961
+ recording a charge occurrence, may still be new work.
962
+
963
+ Identifying "the same purchase" is the consumer's problem, and the envelope does
964
+ not solve it for every store. `originalTransactionId` serves where the store
965
+ issues one. Where it does not, the consumer keys on a binding it established
966
+ itself — but neither `userId` nor `productId` is safe alone: `userId` is absent
967
+ until a purchase is bound (§2.4), and `productId` changes by design on
968
+ `subscription.product_changed`. A consumer that cannot establish a stable key
969
+ cannot derive current state from this stream. If it needs current state and the
970
+ provider declares the entitlements profile, it uses `subscriptionStatus` or
971
+ `entitlements` (§4.2–§4.3). Otherwise it falls back to an emitter-specific
972
+ authoritative status source.
973
+
974
+ Events derived from one notification share an `occurredAt`. This
975
+ specification sets no tiebreaker among them, so a consumer MUST NOT read equal
976
+ timestamps as a contradiction.
977
+
978
+ #### 9.4.5 Destination safety
979
+
980
+ An emitter MUST refuse to deliver to a destination that is not public HTTPS.
981
+ Specifically it MUST reject: non-`https` schemes; credentials embedded in the
982
+ URL; and any address that is not globally routable unicast. The last category
983
+ includes unspecified, loopback, private, shared/CGNAT, link-local,
984
+ documentation, benchmarking, multicast, reserved, and IPv6 unique-local ranges,
985
+ including IPv4-mapped IPv6 spellings such as `::ffff:127.0.0.1`, which URL
986
+ parsers normalize into a form that defeats textual checks.
987
+
988
+ An emitter MUST NOT follow redirects and MUST validate **every** address a
989
+ hostname resolves to. It MUST also connect only to a validated public address,
990
+ either by pinning that address for the connection or by verifying the connected
991
+ peer before sending any request bytes. A second DNS answer must not be able to
992
+ substitute a private target.
993
+
994
+ This is a guard, not a substitute for network egress policy: DNS can still
995
+ resolve a public name to an address the operator did not intend.
996
+
997
+ ### 9.5 Example consumer flow
998
+
999
+ One subscription, four events, and the mistake they are designed to prevent.
1000
+ The quoted payloads are abridged from files in `examples/`, which the test suite
1001
+ validates in full.
1002
+
1003
+ #### The customer renews
1004
+
1005
+ Apple sends `DID_RENEW` with no subtype. §9.2 therefore selects the
1006
+ unconditional row and the event is a renewal, not a recovery — Apple marks a
1007
+ recovery with `BILLING_RECOVERY`. It emits `examples/subscription-renewed.json`:
1008
+
1009
+ ```json
1010
+ {
1011
+ "eventType": "subscription.renewed",
1012
+ "subscription": {
1013
+ "state": "Active",
1014
+ "expiresAt": 1758979200000,
1015
+ "active": true
1016
+ },
1017
+ "price": { "currency": "USD", "amountMicros": 9990000, "provenance": "store" }
1018
+ }
1019
+ ```
1020
+
1021
+ The consumer verifies the signature, checks `eventId` against its
1022
+ deduplication store, and books 9.99 USD of revenue — safely, because
1023
+ `provenance` is `store`, meaning Apple asserted the amount rather than the
1024
+ backend inferring it.
1025
+
1026
+ The entitlement gate did not move: the customer was entitled before and is
1027
+ entitled after. **No entitlement event is emitted.** A consumer that regranted
1028
+ access on every lifecycle event would be doing pointless work here.
1029
+
1030
+ #### The customer cancels
1031
+
1032
+ `examples/subscription-canceled.json`:
1033
+
1034
+ ```json
1035
+ {
1036
+ "eventType": "subscription.canceled",
1037
+ "subscription": {
1038
+ "state": "Active",
1039
+ "expiresAt": 1758979200000,
1040
+ "willRenew": false,
1041
+ "active": true
1042
+ }
1043
+ }
1044
+ ```
1045
+
1046
+ **This is the trap.** The event is called `canceled`, and access must not be
1047
+ revoked. The customer paid through `expiresAt`, so `state` is still `Active`,
1048
+ `active` is still `true`, and only `willRenew` has flipped. Again no entitlement
1049
+ event, because the gate did not move.
1050
+
1051
+ A consumer that switched on `eventType` and revoked here would cut off a paying
1052
+ customer weeks early. A consumer that reads `subscription.active` cannot make
1053
+ that mistake. That is why §2.3 makes `active` the field to read and the
1054
+ predicate normative.
1055
+
1056
+ #### The subscription lapses
1057
+
1058
+ At `expiresAt` the store reports expiry. Now the gate does move, so the backend
1059
+ emits two events — the lifecycle fact, then the entitlement delta:
1060
+
1061
+ ```text
1062
+ subscription.expired state: Expired, active: false
1063
+ entitlement.revoked examples/entitlement-revoked.json
1064
+ ```
1065
+
1066
+ The consumer revokes access on `entitlement.revoked`. It could equally act on
1067
+ `subscription.expired`, but the entitlement event is the one that carries the
1068
+ same meaning for every store — including a store that produces no subscription
1069
+ lifecycle at all (§10).
1070
+
1071
+ > On such a store the event arrives with **no `subscription` member**, because
1072
+ > there is no canonical record to snapshot. `eventType` alone then carries the
1073
+ > access decision, which is why the reference consumer below handles both.
1074
+
1075
+ #### What the consumer had to know
1076
+
1077
+ Nothing about Apple. The same events arrive in the same shape from Google and
1078
+ from any other backend implementing this specification. The receiving endpoint
1079
+ verifies and validates the event, atomically places a new `eventId` in a durable
1080
+ inbox, and acknowledges quickly (§9.4.3). A retry with the same ID is a no-op. A
1081
+ worker then applies effects idempotently on that same ID:
1082
+
1083
+ ```js
1084
+ async function receive(headers, rawBody, secret) {
1085
+ if (!verifySignature(headers, rawBody, secret)) return 401; // §9.4.2
1086
+
1087
+ let event;
1088
+ try {
1089
+ event = JSON.parse(rawBody);
1090
+ } catch {
1091
+ return 400;
1092
+ }
1093
+
1094
+ if (typeof event.eventVersion !== "string") return 400;
1095
+ // A redelivery of an unsupported major will not become readable.
1096
+ if (event.eventVersion.split(".")[0] !== "1") return 200;
1097
+ if (!validateCommerceEventV1(event)) return 400;
1098
+
1099
+ // The allowlist is generated from the contract. Never use a prefix test:
1100
+ // MINOR versions add types that a pinned consumer must ignore safely.
1101
+ if (!isKnownEventTypeV1(event.eventType)) return 200;
1102
+
1103
+ await inbox.enqueueOnce(event.eventId, event); // atomic and durable
1104
+ return 202;
1105
+ }
1106
+
1107
+ async function process(event) {
1108
+ // §9.4.4: stale snapshots cannot overwrite newer state, but other effects may
1109
+ // still be new work.
1110
+ const mayApplyState = !(await isOlderThanAppliedState(event));
1111
+
1112
+ if (mayApplyState && event.userId && event.subscription) {
1113
+ await applyAccessSnapshotOnce(event.eventId, event.userId, {
1114
+ productId: event.subscription.productId,
1115
+ active: event.subscription.active,
1116
+ expiresAt: event.subscription.expiresAt,
1117
+ }); // never grants at or beyond expiresAt
1118
+ } else if (
1119
+ mayApplyState &&
1120
+ ["entitlement.granted", "entitlement.revoked"].includes(event.eventType)
1121
+ ) {
1122
+ await setAccessOnce(
1123
+ event.eventId,
1124
+ event.userId,
1125
+ event.productId,
1126
+ event.eventType === "entitlement.granted",
1127
+ );
1128
+ }
1129
+
1130
+ const CHARGE_EVENTS = new Set([
1131
+ "subscription.started",
1132
+ "subscription.renewed",
1133
+ "subscription.recovered",
1134
+ ]);
1135
+ if (
1136
+ CHARGE_EVENTS.has(event.eventType) &&
1137
+ event.price?.provenance === "store"
1138
+ ) {
1139
+ await recordRevenueOnce(event.eventId, event.price);
1140
+ }
1141
+
1142
+ // §14 carries no refund amount; record the occurrence, not a guessed debit.
1143
+ if (event.eventType === "subscription.refunded") {
1144
+ await recordRefundOnce(event.eventId, event.transactionId);
1145
+ }
1146
+
1147
+ await inbox.complete(event.eventId);
1148
+ }
1149
+ ```
1150
+
1151
+ ---
1152
+
1153
+ ## 10. Provider capabilities
1154
+
1155
+ Stores are not equivalent, and this specification never invents lifecycle
1156
+ semantics to make them look uniform. An emitter SHOULD publish a capability
1157
+ declaration (generated validator:
1158
+ `generated/schemas/provider-capabilities.schema.json`) so a consumer can branch
1159
+ on what is actually observable.
1160
+
1161
+ An implementation SHOULD publish its descriptor somewhere a consumer can fetch
1162
+ it, and SHOULD document where. This version deliberately fixes no location: a
1163
+ backend may serve it, ship it beside its API documentation, or hand it over out
1164
+ of band. Nothing in the contract depends on retrieving it; a consumer that
1165
+ cannot fetch it asks its emitter. The document states `specVersion` — the
1166
+ version of _this specification_ it was
1167
+ written against, which is a different quantity from an event body's
1168
+ `eventVersion` even though both read `1.0` today — the event types it can emit,
1169
+ and its per-store capabilities — enough for a consumer, an operator, or a
1170
+ tool to determine compatibility without reading prose or guessing. It carries no
1171
+ commerce data, so it is safe to expose.
1172
+
1173
+ Each capability carries **two** booleans, deliberately separate:
1174
+
1175
+ - `provider` — what the store's own API offers.
1176
+ - `implementation` — what this backend actually consumes.
1177
+
1178
+ They differ in practice. Amazon publishes Real-Time Notifications that a given
1179
+ backend may not have integrated; that is an implementation gap, not a store
1180
+ limitation, and collapsing the two into one boolean hides which one it is. A
1181
+ `notes` string is **required** whenever either is false or the two disagree.
1182
+
1183
+ `examples/provider-capabilities.json` is the reference implementation's own
1184
+ descriptor. Read its `implementation` axis as one backend's answer, not as the
1185
+ specification's; its `notes` say what each store's surface does and does not
1186
+ report.
1187
+
1188
+ A store whose descriptor declares `serverNotifications.implementation: false`
1189
+ produces **no** notification-derived lifecycle events from that emitter. The
1190
+ `provider` axis explains whether the gap belongs to the store or the
1191
+ implementation. A consumer MUST NOT infer absence of a subscription from
1192
+ absence of events. When no descriptor is available, every capability is unknown
1193
+ and the consumer must use an emitter-specific status source if it needs an
1194
+ answer; a missing optional descriptor never upgrades silence into evidence.
1195
+
1196
+ Such a store is not necessarily silent, though. Where an authoritative endpoint
1197
+ can be re-asked on a schedule, an emitter can still observe the entitlement
1198
+ answer changing and emit `entitlement.granted` / `entitlement.revoked` from it —
1199
+ the mapping table records this per store as `derivableByPolling`. It never
1200
+ yields subscription lifecycle: polling reveals that access changed, never which
1201
+ transition caused it. This version specifies no cadence, so an emitter that does
1202
+ it MUST still declare its capabilities honestly rather than claim parity with a
1203
+ store that pushes notifications.
1204
+
1205
+ For such an event, `occurredAt` MUST be the time the emitter observed the new
1206
+ authoritative answer and `processedAt` is when it derived the event. The actual
1207
+ gate change may have happened at any time since the preceding observation, so
1208
+ the emitter MUST NOT backdate it to a guessed transition time (§9.3).
1209
+
1210
+ ### 10.1 Profiles and bindings
1211
+
1212
+ Two optional members extend the descriptor for providers that serve the
1213
+ operation surface. `profiles` maps each served profile name to its version;
1214
+ `bindings` does the same for `rest` and `graphql`. Declaring a binding means
1215
+ every declared profile operation is reachable over it, and declaring either
1216
+ is a conformance claim (§11): a provider MUST NOT declare a profile or
1217
+ binding it serves partially. Both maps are open — a consumer ignores a key
1218
+ it does not recognise — and both are absent on a descriptor from an
1219
+ events-only emitter that predates the operation surface, which is exactly
1220
+ how a consumer tells the two kinds of backend apart.
1221
+
1222
+ ---
1223
+
1224
+ ## 11. Conformance
1225
+
1226
+ ### 11.1 Levels
1227
+
1228
+ Binding support is declared per provider (§10), and conformance is judged
1229
+ per binding:
1230
+
1231
+ - **REST-conformant** — every declared profile's operations pass the vectors
1232
+ over the REST binding.
1233
+ - **GraphQL-conformant** — the same over the GraphQL binding.
1234
+ - **Dual-binding** — both, plus cross-binding parity: every deterministic
1235
+ vector's normalized outcome agrees across the two bindings.
1236
+
1237
+ A provider claims **full provider portability** only when it implements the
1238
+ shared operation profiles it declares, at least one portable binding, and
1239
+ the Event Delivery profile, and passes conformance for all of them.
1240
+ **Dual-binding reference implementation** describes an implementation that
1241
+ passes both bindings; IAPKit is the open-source one.
1242
+
1243
+ ### 11.2 The portable runner
1244
+
1245
+ `conformance/` ships a runner any provider can point at its own backend:
1246
+
1247
+ ```js
1248
+ import Ajv from "ajv/dist/2020.js";
1249
+ import {
1250
+ createRestAdapter,
1251
+ createGraphqlAdapter,
1252
+ runConformance,
1253
+ } from "@hyodotdev/openiap-commerce-protocol/conformance";
1254
+
1255
+ const report = await runConformance({
1256
+ adapters: [
1257
+ createRestAdapter({ baseUrl, fetch, credentials }),
1258
+ createGraphqlAdapter({ url: graphqlUrl, fetch, credentials }),
1259
+ ],
1260
+ Ajv,
1261
+ // The same role-to-credential map the adapters use — required, so the
1262
+ // runner can reject an error message that echoes a credential.
1263
+ credentials,
1264
+ eventsAdapter, // required when the descriptor declares the events profile
1265
+ });
1266
+ ```
1267
+
1268
+ It is offline and decentralized by construction: it talks only through the
1269
+ `fetch` it is given, judges only against the generated schemas, manifest,
1270
+ and vectors, and imports no implementation — the package's own suite proves
1271
+ it by certifying a minimal mock provider that shares no code with IAPKit.
1272
+ The caller supplies the Ajv 2020 class because the published runtime keeps
1273
+ zero dependencies. `credentials` maps the two §5 roles to whatever secrets
1274
+ the provider under test issued for the run. A provider whose descriptor
1275
+ declares the `events` profile also passes an `eventsAdapter` — the runner
1276
+ drives §9 signing, verification, the delivery envelope, response semantics,
1277
+ the §2.3 entitlement gate, and the emission rules through it, and rejects a
1278
+ signing-only adapter.
1279
+
1280
+ ### 11.3 What the vectors prove — and what they cannot
1281
+
1282
+ The operation vectors (`generated/vectors/operations.json`) exercise auth
1283
+ negatives, invalid and unknown-member inputs, unsupported stores, mismatched
1284
+ evidence, idempotent repeats, tokenless responses, error-code and
1285
+ HTTP-status agreement, capability honesty, and REST/GraphQL parity. Their
1286
+ purchase evidence is fake but well-formed, so a provider without store
1287
+ credentials still verifies its transport contract; a verdict for that
1288
+ evidence is accepted as either a schema-valid result or
1289
+ `VERIFICATION_FAILED`.
1290
+
1291
+ They therefore certify the **contract**, not the **stores**: passing says
1292
+ nothing about whether real Apple or Google receipts validate correctly.
1293
+ Beyond the operation vectors, the runner also checks the capability
1294
+ descriptor's version agreement against the manifest and — on the GraphQL
1295
+ binding — probes that the endpoint is a real executor (a malformed document,
1296
+ an undefined field, and a mistyped variable must each be rejected, without
1297
+ echoing the submitted value; introspection, where enabled, must agree
1298
+ STRUCTURALLY with the generated signature — kinds, field and argument types
1299
+ with their nullability, input members, closed enum value sets, and closed
1300
+ object member sets. A compatible MINOR may add types, nullable arguments, and
1301
+ members to open objects; it cannot extend a closed object). Event Delivery conformance is likewise separate — §9's
1302
+ signature, delivery-envelope, response-semantics, and lifecycle vectors
1303
+ cover it, driven through the provider's events adapter — and a signing-only
1304
+ provider does not pass it. The events vectors do not reach everything §9
1305
+ requires of a production emitter: the §9.3 event-document schema, §9.4.4
1306
+ backoff and dead-lettering, §9.4.5 destination safety, and §9.2 store
1307
+ mapping are certified by an implementation's own tests, not by this
1308
+ adapter surface. And a provider can pass while serving fixture data;
1309
+ conformance is a floor, not an audit.
1310
+
1311
+ ---
1312
+
1313
+ ## 12. Versioning
1314
+
1315
+ The protocol version is `MAJOR.MINOR`, with each component written as a
1316
+ non-negative decimal integer without a leading zero unless the component is
1317
+ exactly `0`. It is independent of the npm package version used to distribute
1318
+ these files. An emitter MUST set `eventVersion` to the protocol version that
1319
+ defines the emitted body; a capability descriptor and mapping table use the same
1320
+ value as `specVersion`. **Consumers pin on the major.**
1321
+
1322
+ | Change | Version impact |
1323
+ | ------------------------------------------------------------------------------------------------- | -------------- |
1324
+ | New optional member on an open object | MINOR |
1325
+ | New event type | MINOR |
1326
+ | New value in an open value space (`store`, `environment`, `cancellationReason`, `eventType`) | MINOR |
1327
+ | New operation, new profile, or new optional operation input member | MINOR |
1328
+ | New protocol error code, or a new evidence member for a new store | MINOR |
1329
+ | Member removed, renamed, or given new meaning | MAJOR |
1330
+ | Member type, nullability, or requiredness changed | MAJOR |
1331
+ | Member added to or removed from a closed enumeration | MAJOR |
1332
+ | Member added to a closed object (`Support`, `Mapping`, a tokenless result, or the error envelope) | MAJOR |
1333
+ | Operation removed, or its path, method, auth role, or success status changed | MAJOR |
1334
+ | New required operation input member | MAJOR |
1335
+
1336
+ A consumer MUST ignore members it does not recognise on an open object, and MUST
1337
+ ignore event types it does not recognise rather than failing. This is what makes
1338
+ MINOR additions safe, and the event envelope permits unknown members for exactly
1339
+ this reason.
1340
+
1341
+ Some object types are deliberately closed instead, and reject members they do
1342
+ not declare. A **capability value** (`Support`) is closed because a fourth key
1343
+ beside `provider`, `implementation`, and `notes` would change what that
1344
+ capability means while validating silently. A **mapping row** is closed because
1345
+ an unrecognised qualifier would leave a reader selecting the row on fewer
1346
+ conditions than its author intended. The **tokenless server-read results**
1347
+ (`SubscriptionStatusSnapshot`, `SubscriptionStatusResult`, `EntitlementsResult`)
1348
+ are closed because "no purchase token, store transaction identity, signed
1349
+ receipt, or provider-internal record identifier appears here" (§4.2) has to be
1350
+ enforced by the schema itself: an open object would let a provider smuggle a
1351
+ raw receipt or an internal row id past every validator. The **REST error
1352
+ envelope** (`ProtocolError`, `ProtocolErrorResponse`) is closed for the same
1353
+ reason: a failure response is the easiest place to smuggle a member past the
1354
+ tokenless rules, because callers rarely inspect one. Adding a member to any
1355
+ of these is a MAJOR change.
1356
+
1357
+ Note the surrounding containers stay open: a _new_ capability axis, or a new
1358
+ member on a store's mapping entry, is a MINOR addition that older consumers
1359
+ ignore.
1360
+
1361
+ An emitter that cannot determine an **optional event member** MUST omit it rather
1362
+ than send a placeholder. Zero, false, and empty values MAY be sent when they are
1363
+ the known value; they MUST NOT stand in for unknown. An emitter MUST know every
1364
+ required member before emitting the event. The declared `Unknown` subscription
1365
+ state is the one explicit sentinel and is not an empty placeholder. These rules
1366
+ do not apply to capabilities: an unimplemented capability is declared with
1367
+ `implementation: false` and a `notes` explanation (§10), which is a statement.
1368
+
1369
+ Profiles and bindings version independently of the protocol, on the same
1370
+ MAJOR.MINOR rule; a capability descriptor declares the versions it serves
1371
+ (§10), and a caller pins each on its major. The REST path's `v1` segment is
1372
+ the protocol major, so a provider can serve two majors side by side during a
1373
+ migration.
1374
+
1375
+ ---
1376
+
1377
+ ## 13. Provider switching
1378
+
1379
+ An adopter may replace the backend behind this contract — a self-hosted
1380
+ deployment for a managed one, a managed one for their own — and their downstream
1381
+ integrations SHOULD survive it. What follows is what actually carries across, and
1382
+ what does not.
1383
+
1384
+ The following shows what a swap carries across and what it does not, from the
1385
+ consumer's side:
1386
+
1387
+ ```mermaid
1388
+ flowchart LR
1389
+ consumer["A consumer written against this specification<br/>— unchanged by the swap"]
1390
+ A["Backend A<br/>before"]
1391
+ B["Backend B<br/>after"]
1392
+ A -- "events (§9)" --> consumer
1393
+ B -- "events (§9)" --> consumer
1394
+
1395
+ subgraph carries ["Carries across"]
1396
+ c1["event types · envelope shape · entitlement predicate"]
1397
+ c2["signature scheme · per-store semantics"]
1398
+ c3["sourceStoreEventId — the store's own notification id"]
1399
+ c4["userId, when the adopter assigns it"]
1400
+ end
1401
+ subgraph breaks ["Does not carry across"]
1402
+ n1["eventId and projectId — emitter-assigned, a new id space"]
1403
+ n2["a consumer deduplicating only on eventId processes a cutover overlap twice"]
1404
+ n3["sourceStoreEventId is not a repair — siblings legitimately share one"]
1405
+ end
1406
+ ```
1407
+
1408
+ **Carries across.** Event types, envelope shape, the entitlement predicate,
1409
+ signature scheme, and per-store semantics are all defined here, not by a backend.
1410
+ `sourceStoreEventId` carries the store's own notification identifier, so it
1411
+ denotes the same real-world fact no matter which implementation observed it. A
1412
+ consumer written against this specification does not change.
1413
+
1414
+ **Does not carry across.** `eventId` and `projectId` are emitter-assigned. A new
1415
+ implementation issues identifiers from its own space, so a consumer's
1416
+ `eventId` deduplication history has no overlap with the events the new backend
1417
+ sends. During a cutover in which both backends observe the same store
1418
+ notification, a consumer deduplicating only on `eventId` will process that fact
1419
+ twice.
1420
+
1421
+ `sourceStoreEventId` is not a repair for this. Several events legitimately share
1422
+ one — the lifecycle event and its entitlement delta, and any later event derived
1423
+ from the same notification — so no key built on it separates a duplicate from a
1424
+ sibling. A cutover needs a plan that spans both backends, not a consumer-side
1425
+ key.
1426
+
1427
+ An emitter SHOULD populate `userId` with the adopter's own user identifier
1428
+ rather than one it mints, so that the binding survives the emitter.
1429
+
1430
+ Everything else an adopter must do to move — exporting subscription rows,
1431
+ re-registering store credentials, redirecting store notifications — is
1432
+ implementation-specific operational work. It is deliberately outside this
1433
+ specification, which governs the observable contract rather than any backend's
1434
+ storage or tooling.
1435
+
1436
+ ---
1437
+
1438
+ ## 14. Out of scope
1439
+
1440
+ - **One-time purchase events.** The taxonomy covers subscriptions and
1441
+ entitlements only. Refund and revocation of non-subscription purchases have no
1442
+ event type yet. An `entitlement.*` delta may still report access to a durable
1443
+ product; what is absent is the one-time purchase's economic-event taxonomy.
1444
+ - **Refund amounts and partial refunds.** `subscription.refunded` reports that a
1445
+ refund occurred, not how much was returned.
1446
+ - **Trial and introductory-offer state.** Offers are catalog metadata here, not
1447
+ a property of a live subscription.
1448
+ - **Storefront and country.**
1449
+ - **Quantity.** Every event describes a single unit.
1450
+ - **Key identifiers in signatures.** See §9.4.2.
1451
+ - **A complete transition state machine.** §9.1 and the GraphQL invariant pin the
1452
+ event/snapshot pairs whose meaning would otherwise contradict itself, but do
1453
+ not prescribe every possible prior state or store transition. The emission
1454
+ vectors use a cross-product only to isolate the entitlement-delta rule; a row
1455
+ in that matrix is not by itself a valid wire event.
1456
+ - **Emitter-side delivery policy beyond retries.** An emitter may drop a
1457
+ destination that keeps failing, filter by event type, or prune after a
1458
+ retention window. All three change what a consumer receives, and a consumer
1459
+ needing guarantees about them must get those from its emitter, not here.
1460
+ - **Catalog, webhook-destination, and analytics operations.** Managing
1461
+ products, registering event destinations, and revenue reporting stay
1462
+ provider surface; §4 covers the data plane a switchover must preserve.
1463
+ - **Credential provisioning and provider dashboards.** How a provider issues
1464
+ §5 credentials, uploads store credentials, or renders a console is
1465
+ management plane.
1466
+ - **Historical data migration automation.** §13 states what carries across a
1467
+ provider switch; moving the historical rows is operational work this
1468
+ contract does not script.
1469
+ - **Store-credential certification.** Conformance never talks to a store
1470
+ (§11.3), so nothing here certifies that a provider's Apple or Google
1471
+ integration is correctly configured.