@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.
- package/CONVENTION.md +168 -0
- package/DESIGN.md +1056 -0
- package/README.md +227 -5
- package/SPEC.md +1471 -0
- package/conformance/index.d.ts +303 -0
- package/conformance/index.mjs +2126 -0
- package/conformance/mock-provider.mjs +491 -0
- package/examples/entitlement-granted-no-subscription.json +12 -0
- package/examples/entitlement-revoked.json +21 -0
- package/examples/provider-capabilities.json +209 -0
- package/examples/store-event-mapping.json +287 -0
- package/examples/subscription-canceled.json +22 -0
- package/examples/subscription-product-changed.json +30 -0
- package/examples/subscription-renewed.json +29 -0
- package/examples/verify-purchase-request.json +6 -0
- package/examples/verify-purchase-result.json +7 -0
- package/generated/bindings/graphql-operations.json +87 -0
- package/generated/bindings/http-binding.json +143 -0
- package/generated/bindings/introspection-signature.json +320 -0
- package/generated/bindings/operations-sdl.json +4 -0
- package/generated/bindings/operations.graphql +366 -0
- package/generated/commerce-protocol.graphql +1219 -0
- package/generated/openapi/commerce-protocol.openapi.json +1413 -0
- package/generated/schemas/commerce-event.schema.json +499 -0
- package/generated/schemas/commerce-protocol.bundle.schema.json +1576 -0
- package/generated/schemas/operations.schema.json +578 -0
- package/generated/schemas/primitives.schema.json +101 -0
- package/generated/schemas/provider-capabilities.schema.json +205 -0
- package/generated/schemas/store-event-mapping.schema.json +211 -0
- package/generated/vectors/lifecycle.json +908 -0
- package/generated/vectors/operations.json +1122 -0
- package/package.json +62 -12
- package/schema/01-primitives.graphql +102 -0
- package/schema/02-commerce-event.graphql +195 -0
- package/schema/03-provider-capabilities.graphql +139 -0
- package/schema/04-store-event-mapping.graphql +98 -0
- package/schema/05-operations.graphql +461 -0
- package/schema/06-compiler-vocabulary.graphql +139 -0
- package/schema/07-protocol-metadata.graphql +76 -0
- package/src/index.d.ts +63 -0
- package/src/index.mjs +121 -0
- 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.
|