brainerce 1.59.0 → 2.0.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/README.md CHANGED
@@ -55,20 +55,21 @@ Every Brainerce storefront must include **all mandatory features** below. Featur
55
55
  | Product detail with variant picker, stock, price | `client.getProductBySlug()` + helpers | ✅ |
56
56
  | Buyer customization fields (engraving, uploads, select) | `product.customizationFields`, `client.uploadCustomizationFile()` | ✅ |
57
57
  | Cart (add, update, remove, coupon, totals) | `client.addToCart()`, `getCartTotals(cart)` | ✅ |
58
- | Inventory reservation countdown | Cart expiry timestamp from `client.getCart()` | ✅ |
58
+ | Inventory reservation countdown | Cart expiry timestamp from `client.getCart(cartId)` | ✅ |
59
59
  | Full checkout end-to-end with payment | `setShippingAddress → selectShippingMethod → getPaymentProviders → pay → handlePaymentSuccess → waitForOrder` | ✅ |
60
60
  | Order confirmation (clear cart + wait for real order) | `client.handlePaymentSuccess()`, `client.waitForOrder()` | ✅ |
61
61
  | Register + email verification flow | `client.registerCustomer()`, `client.verifyEmail()` | ✅ |
62
62
  | Login + verification branch | `client.loginCustomer()` | ✅ |
63
63
  | Forgot / reset password | `client.forgotPassword()`, `client.resetPassword()` | ✅ |
64
64
  | OAuth sign-in buttons + callback handler | `client.getAvailableOAuthProviders()` | ✅ |
65
- | Account area (profile + order history) | `client.getMyProfile()`, `client.getMyOrders()` | ✅ |
65
+ | Account area (profile + order history) | `client.getMyProfile()`, `client.updateMyProfile()`, `client.getMyOrders()` | ✅ |
66
66
  | Loyalty & rewards (points balance + tiers + redeem) | `client.getLoyaltyStatus()`, `client.getAvailableRewards()`, `client.getRecommendedReward()`, `client.redeemLoyaltyReward(id)`, `client.reportSocialShare()` | conditional |
67
67
  | Loyalty paid membership (premium subscription) | `client.getMembershipPlans()`, `client.getMySavedPaymentMethods()`, `client.subscribeToMembership(params)`, `client.cancelMembership()` | conditional |
68
68
  | Embeddable loyalty widget (points + rewards on ANY site) | `client.getLoyaltyWidgetSession()` | conditional |
69
- | Global header: cart count + search autocomplete | `client.getCart()`, `client.getSearchSuggestions(query)` | ✅ |
69
+ | Global header: cart count + search autocomplete | `client.smartGetCart()`, `client.getSearchSuggestions(query)` | ✅ |
70
70
  | Discount banners + product badges | `client.getDiscountBanners()`, `client.getProductDiscountBadge(productId)` | ✅ |
71
71
  | Product reviews on PDP + JSON-LD aggregateRating | `client.listProductReviews(id)`, `client.submitProductReview(id, …)` | ✅ |
72
+ | Customer photos on reviews | `client.uploadReviewPhoto(productId, file)`, then `imageKeys` on submit | conditional |
72
73
  | Site chrome (header + footer + announcement bar) | `client.content.header.get()`, `client.content.footer.get()`, `client.content.announcement.list()` | ✅ |
73
74
  | FAQ page | `client.content.faq.get('main', locale)` | conditional |
74
75
  | Static pages catch-all (`/pages/[slug]`) | `client.content.page.getBySlug(slug, locale)` | conditional |
@@ -98,6 +99,7 @@ Violating any of these causes production incidents or broken orders. Read them b
98
99
 
99
100
  - ALWAYS handle the `requiresVerification` flag in `registerCustomer` and `loginCustomer` responses. If true, route to the verify-email step BEFORE treating the user as logged in.
100
101
  - ALWAYS build the verify-email, forgot-password, and reset-password flows even when the store currently has email verification disabled. They auto-hide when unused.
102
+ - ALWAYS read `requireBirthday` from `getStoreInfo()` before rendering the signup form. When it is true the merchant made the birthday mandatory on that sales channel, and `registerCustomer` returns HTTP 400 unless you send both `birthMonth` (1-12) and `birthDay` (1-31). Month and day only, never a year.
101
103
  - ALWAYS build OAuth button placeholders and a callback handler even when no OAuth provider is configured.
102
104
  - NEVER silently swallow auth errors. Render the specific error (invalid credentials, expired token, rate limited).
103
105
 
@@ -170,17 +172,19 @@ These sequences are non-negotiable. The order of SDK calls matters.
170
172
  5. Confirm payment using the provider's flow (Stripe Elements `stripe.confirmCardPayment`, PayPal button, redirect, etc.).
171
173
  6. On the confirmation page, **always call both**:
172
174
  ```ts
173
- await client.handlePaymentSuccess(checkoutId); // clears cart
175
+ client.handlePaymentSuccess(checkoutId); // synchronous, clears cart. Do NOT await it.
174
176
  const order = await client.waitForOrder(checkoutId); // polls until order exists
175
177
  ```
176
178
  7. Display `checkout.lineItems` (not `cart.items`) on the order summary.
177
179
 
178
180
  ### Registration flow
179
181
 
180
- 1. Collect email, password, first name, last name.
182
+ 1. Collect email, password, first name, last name. Read `requireBirthday` from `getStoreInfo()`: when it is true, collect a birthday month and day as well, because the register call is rejected without them.
181
183
  2. Call `registerCustomer`:
182
184
  ```ts
183
185
  const result = await client.registerCustomer({ email, password, firstName, lastName });
186
+ // Channel requires a birthday? Send both fields, never a year:
187
+ // { email, password, firstName, lastName, birthMonth: 4, birthDay: 17 }
184
188
  ```
185
189
  3. Branch on `result.requiresVerification`:
186
190
  - `true` → store token temporarily, route to verify-email UI (do NOT set token yet)
@@ -206,7 +210,22 @@ These sequences are non-negotiable. The order of SDK calls matters.
206
210
  ### Order confirmation flow
207
211
 
208
212
  1. Read `checkoutId` from URL or session.
209
- 2. `await client.handlePaymentSuccess(checkoutId)` mandatory, clears cart so purchased items don't show on next visit.
213
+ 2. `client.handlePaymentSuccess(checkoutId)` is mandatory. It clears the cart so purchased items don't show on the next visit. It is **synchronous** and returns a plain object, not a promise, so awaiting it is a no-op that only looks like it did something:
214
+
215
+ ```ts
216
+ const { cleared, mode, userType, itemsRemoved } = client.handlePaymentSuccess(checkoutId);
217
+ // mode: 'full' the whole cart went, the normal case
218
+ // mode: 'partial' a partial checkout, so only the purchased lines went.
219
+ // `itemsRemoved` counts them and the rest stay in the cart
220
+ // mode: 'none' nothing to do, because this checkout was already handled
221
+ // in this browser session. React Strict Mode runs effects
222
+ // twice and a refresh re-runs the page, so this is the
223
+ // normal repeat-call answer: SUCCESS, not a failure.
224
+ // `cleared` is false here. Never show an error on it.
225
+ // userType: 'guest' | 'customer'
226
+ ```
227
+
228
+ Branch on `mode` only for what you render: a `'partial'` result means the shopper still has a cart worth linking to. Never gate `waitForOrder` on `cleared`.
210
229
  3. `const result = await client.waitForOrder(checkoutId)` — polls until the webhook writes the order. `result.status.orderNumber` / `result.status.orderId` are available on success.
211
230
  4. Show a spinner during step 3 (webhook may lag). On timeout: show "we're still processing, check your email" with a link to order history — the order WILL appear there.
212
231
  5. On success: render the order number, or — if your design wants more than that — fetch full details:
@@ -276,7 +295,7 @@ the credential, no customer token needed.
276
295
  ### Inventory reservation flow
277
296
 
278
297
  - Display the countdown from `cart.reservation?.expiresAt` — refresh once per second (`reservation` is optional; only present when a reservation strategy is active).
279
- - On expiry: call `client.getCart()` to refresh. Items whose reservation expired are flagged server-side.
298
+ - On expiry: call `client.getCart(cartId)` to refresh, or `client.smartGetCart()` when you are not tracking a cart id yourself. `getCart` takes the cart id; there is no no-argument form. Items whose reservation expired are flagged server-side.
280
299
  - On the checkout page: if reservation has expired, block payment and show "your cart has expired" with a link back to cart.
281
300
  - Do NOT implement your own timer logic — the SDK is the source of truth.
282
301
 
@@ -1022,8 +1041,9 @@ await client.setCheckoutCustomer(checkout.id, {
1022
1041
  lastName: 'Doe',
1023
1042
  });
1024
1043
 
1025
- // 5. Set shipping address
1044
+ // 5. Set shipping address (email is required here too, even though step 4 sent it)
1026
1045
  await client.setShippingAddress(checkout.id, {
1046
+ email: 'customer@example.com',
1027
1047
  firstName: 'John',
1028
1048
  lastName: 'Doe',
1029
1049
  line1: '123 Main St',
@@ -1112,6 +1132,7 @@ const region = validRegions.some((r) => r.code === address.region) ? address.reg
1112
1132
  // yourself. Use `address.lat`/`address.lng` for your own UI — a map pin, a
1113
1133
  // distance readout — and nothing else.
1114
1134
  await client.setShippingAddress(checkout.id, {
1135
+ email: 'customer@example.com', // required. The resolved address carries no email.
1115
1136
  firstName: 'John',
1116
1137
  lastName: 'Doe',
1117
1138
  ...address,
@@ -1907,7 +1928,8 @@ fields.forEach((field) => {
1907
1928
  // field.minLength, field.maxLength: validation for text fields
1908
1929
  // field.minValue, field.maxValue: validation for number fields
1909
1930
  // field.dateAvailability: constraints for DATE/DATETIME fields (blocked
1910
- // weekdays/dates, min/max date, business hours + slots) — see
1931
+ // weekdays/dates, min/max date, leadTimeMinutes/cutoffTime/maxDaysAhead,
1932
+ // business hours + slots) — see
1911
1933
  // computeAvailableSlots()/getBusinessHoursForDate()/isDateValueAllowed()
1912
1934
  // below. DATE values are sent as "YYYY-MM-DD"; DATETIME as one ISO-8601
1913
1935
  // value — never a date with a slot LABEL glued on ("...T13:00-14:00" is
@@ -2104,6 +2126,160 @@ function ProductDescription({ product }: { product: Product }) {
2104
2126
 
2105
2127
  ---
2106
2128
 
2129
+ ### Product Reviews
2130
+
2131
+ Customer reviews on the product page, with optional customer photos. Reviews
2132
+ publish immediately, there is no pending state, and the merchant hides
2133
+ individual reviews or photos afterwards (see [Review Moderation](#review-moderation)
2134
+ in the Admin API Reference).
2135
+
2136
+ Reading reviews is public: `listProductReviews` and the `avgRating` /
2137
+ `reviewCount` summary need no login. Writing is not. Only customers who bought
2138
+ the product may review it, so `getMyProductReview`, `uploadReviewPhoto`,
2139
+ `submitProductReview`, `updateMyProductReview` and `deleteMyProductReview` all
2140
+ need a customer token set with `setCustomerToken(...)` and return 401 without
2141
+ one. Author name and email come from the customer profile server-side; never
2142
+ send them.
2143
+
2144
+ The product itself carries `avgRating` and `reviewCount` for the star summary
2145
+ and the JSON-LD `aggregateRating`, so a rating badge on a product card costs no
2146
+ extra request.
2147
+
2148
+ #### List Reviews
2149
+
2150
+ ```typescript
2151
+ const { data, meta } = await client.listProductReviews('prod_123', {
2152
+ page: 1,
2153
+ limit: 20,
2154
+ sort: 'photos_first', // 'photos_first' (default) | 'newest'
2155
+ });
2156
+
2157
+ data.forEach((review) => {
2158
+ console.log(review.rating, review.body, review.verifiedPurchase, review.authorName);
2159
+ // review.images is ALWAYS an array, already filtered to the photos shoppers
2160
+ // may see. Set width/height on the <img> so the gallery reserves space
2161
+ // instead of shifting as the photos load.
2162
+ review.images.forEach((img) => console.log(img.thumbnailUrl ?? img.url, img.width, img.height));
2163
+ });
2164
+
2165
+ console.log(meta.total); // total visible reviews
2166
+ ```
2167
+
2168
+ `sort` defaults to `photos_first`: reviews carrying photos lead, newest first
2169
+ within each group. Pass `sort: 'newest'` for plain chronological order. On a
2170
+ store with no review photos the two orders are identical. Hidden reviews are
2171
+ never returned here.
2172
+
2173
+ No customer token is needed for this call: it is the one review call a
2174
+ signed-out shopper can make.
2175
+
2176
+ #### Read the Customer's Own Review State
2177
+
2178
+ Requires a customer token. Call it before rendering any review UI: it answers
2179
+ all four cases in one request, sign in, not eligible, submit, edit.
2180
+
2181
+ ```typescript
2182
+ const { eligible, reason, myReview, photos, myImages } = await client.getMyProductReview('prod_123');
2183
+
2184
+ if (!eligible) {
2185
+ // reason: 'no_eligible_order' | 'reviews_disabled' | 'product_not_found' | null
2186
+ showMessage(reason);
2187
+ } else if (myReview) {
2188
+ renderEditForm(myReview);
2189
+ } else {
2190
+ renderSubmitForm();
2191
+ }
2192
+
2193
+ // The store's live photo policy. Read it instead of hardcoding limits.
2194
+ photos.enabled; // render the file picker only when true
2195
+ photos.maxPerReview; // cap the selection at this
2196
+ photos.maxBytes; // reject oversized files before uploading
2197
+ photos.requiresApproval; // true = tell the customer their photo waits for the merchant
2198
+
2199
+ // The customer's OWN photos, including ones still pending approval, so their
2200
+ // upload looks queued rather than failed. `myReview.images` carries only the
2201
+ // publicly visible subset.
2202
+ myImages.forEach((img) => console.log(img.approvedAt, img.hiddenAt));
2203
+ ```
2204
+
2205
+ #### Upload Photos
2206
+
2207
+ Requires a customer token, and the server re-checks the purchase before it
2208
+ stores any bytes. Upload each file first, then pass the returned keys on submit.
2209
+ Keys, not URLs: the server resolves each one against the store's own assets and
2210
+ rejects anything that is not a review photo.
2211
+
2212
+ ```typescript
2213
+ const { photos } = await client.getMyProductReview(productId);
2214
+
2215
+ if (photos.enabled) {
2216
+ const uploads = await Promise.all(
2217
+ [...fileInput.files]
2218
+ .slice(0, photos.maxPerReview)
2219
+ .map((file) => client.uploadReviewPhoto(productId, file))
2220
+ );
2221
+
2222
+ // Each upload: { key, url, width, height }. `url` is for a local preview only,
2223
+ // `key` is what you send back.
2224
+ const imageKeys = uploads.map((u) => u.key);
2225
+ }
2226
+ ```
2227
+
2228
+ Limits and behaviour:
2229
+
2230
+ - JPEG, PNG, WebP or GIF only. Max 5 MB and 40 megapixels per file. The server
2231
+ checks the real bytes, not the declared MIME type, so a renamed file is rejected.
2232
+ - Throttled to 10 uploads per minute, so a burst returns HTTP 429.
2233
+ - 403 when the store has review photos turned off, or the customer did not buy
2234
+ the product. 400 once the review is already at its photo cap.
2235
+ - EXIF is stripped, so GPS coordinates never reach the storefront, and the
2236
+ orientation tag is applied first, so phone photos stay upright.
2237
+ - A photo uploaded but never attached to a submitted review is reclaimed after 7
2238
+ days.
2239
+
2240
+ #### Submit a Review
2241
+
2242
+ Requires a customer token.
2243
+
2244
+ ```typescript
2245
+ const review = await client.submitProductReview('prod_123', {
2246
+ rating: 5, // 1-5
2247
+ body: 'Arrived beautifully wrapped.', // optional
2248
+ imageKeys, // optional, in display order
2249
+ });
2250
+ ```
2251
+
2252
+ - 403 `no_eligible_order` when the customer never bought the product. Eligibility
2253
+ is SHIPPED for physical products, PAID for downloadable ones.
2254
+ - 409 when they already reviewed this product. Call `updateMyProductReview`
2255
+ instead, which is why you read `getMyProductReview` first.
2256
+
2257
+ #### Edit or Delete Their Own Review
2258
+
2259
+ Requires a customer token. Both calls act on the caller's own review for that
2260
+ product; there is no review id to pass and no way to touch anyone else's.
2261
+
2262
+ ```typescript
2263
+ // Rating, body and photos. authorName and verifiedPurchase are preserved from
2264
+ // the original submit and cannot be changed.
2265
+ await client.updateMyProductReview('prod_123', {
2266
+ rating: 4,
2267
+ body: 'Still good after a month of use.',
2268
+ imageKeys: ['key_a', 'key_b'],
2269
+ });
2270
+
2271
+ // Deleting frees the customer to submit a new review, subject to eligibility.
2272
+ await client.deleteMyProductReview('prod_123');
2273
+ ```
2274
+
2275
+ > **`imageKeys` REPLACES the photo set on update.** Pass the keys you want to
2276
+ > keep, including the existing ones. Omitting the field entirely leaves the
2277
+ > current photos untouched; passing `[]` removes them all. The key of an
2278
+ > already-attached photo is `assetKey` on each entry of
2279
+ > `getMyProductReview().myImages`.
2280
+
2281
+ ---
2282
+
2107
2283
  ### Cart Operations (All Users)
2108
2284
 
2109
2285
  The `smart*` methods work for both guests and logged-in users. Guests use server-side session carts; logged-in users use server carts linked to their account.
@@ -2115,9 +2291,35 @@ await client.smartAddToCart({
2115
2291
  productId: 'prod_123',
2116
2292
  variantId: 'var_456', // Optional: for products with variants
2117
2293
  quantity: 2,
2294
+
2295
+ // Optional: buyer customization values (engraving text, uploaded photo URL,
2296
+ // SELECT / MULTI_SELECT picks). Keys are the `key` of each entry in
2297
+ // product.customizationFields. See "Product customization fields" above.
2298
+ metadata: {
2299
+ engraving_text: 'Happy Birthday!',
2300
+ frame_color: 'Gold',
2301
+ },
2302
+
2303
+ // Optional: modifier-group picks (toppings, sauce, build-your-own).
2304
+ selections: [
2305
+ { modifierGroupId: 'mg_bread', modifierIds: ['m_thick'] },
2306
+ { modifierGroupId: 'mg_toppings', modifierIds: ['m_olive', 'm_bacon'] },
2307
+ ],
2308
+
2309
+ // Optional: nested combo picks, keyed by the PARENT modifierId. Max 3 levels.
2310
+ nestedByModifierId: {
2311
+ m_side_drink: [{ modifierGroupId: 'mg_size', modifierIds: ['m_large'] }],
2312
+ },
2118
2313
  });
2119
2314
  ```
2120
2315
 
2316
+ > `metadata`, `selections` and `nestedByModifierId` are the same three fields the
2317
+ > low-level `addToCart(cartId, item)` takes, and they behave identically here.
2318
+ > Reach for `smartAddToCart` on a storefront: it resolves the right cart for a
2319
+ > guest or a logged-in shopper, so you never need a `cartId` of your own.
2320
+ > Validation stays server-side, so a bad modifier set still comes back as a
2321
+ > `MODIFIER_VALIDATION_FAILED` envelope on `BrainerceError.details`.
2322
+
2121
2323
  #### Get Cart
2122
2324
 
2123
2325
  ```typescript
@@ -2571,6 +2773,7 @@ const checkout = await client.setCheckoutCustomer(checkoutId, {
2571
2773
 
2572
2774
  ```typescript
2573
2775
  const { checkout, rates } = await client.setShippingAddress(checkoutId, {
2776
+ email: 'customer@example.com', // REQUIRED, on every call
2574
2777
  firstName: 'John',
2575
2778
  lastName: 'Doe',
2576
2779
  line1: '123 Main St',
@@ -2589,6 +2792,11 @@ const { checkout, rates } = await client.setShippingAddress(checkoutId, {
2589
2792
  console.log(rates); // ShippingRate[]
2590
2793
  ```
2591
2794
 
2795
+ > **`email` is required, including for logged-in customers and including when
2796
+ > you already sent it to `setCheckoutCustomer`.** It is validated before any
2797
+ > service code runs, so the server never fills it in from the customer record;
2798
+ > omit it or send an empty string and the call fails with a 400.
2799
+
2592
2800
  > **Always pass `placeId` when you use address autocomplete.** The server
2593
2801
  > re-resolves it to the address's exact coordinates, which is how stores that
2594
2802
  > draw their delivery areas on a map ("polygon" zones) decide whether they
@@ -2681,7 +2889,8 @@ const updatedCheckout = await client.setCheckoutCustomFields(checkoutId, {
2681
2889
  **DATE / DATETIME fields with availability constraints**
2682
2890
 
2683
2891
  A `DATE`/`DATETIME` field's `dateAvailability` (blocked weekdays, blocked specific
2684
- dates, min/max date range, and for `DATETIME` business hours + time
2892
+ dates, min/max date range, the relative bounds `leadTimeMinutes` / `cutoffTime` /
2893
+ `maxDaysAhead`, and — for `DATETIME` — business hours + time
2685
2894
  slots) is a merchant-configured restriction on which values the customer may
2686
2895
  pick. Use `computeAvailableSlots()` / `getBusinessHoursForDate()` /
2687
2896
  `isDateValueAllowed()` to drive your own date-picker/slot-picker UI — the SDK
@@ -2700,19 +2909,24 @@ const { timezone } = await client.getStoreInfo(); // IANA string, e.g. "Asia/Jer
2700
2909
  const deliveryField = fields.find((f) => f.key === 'delivery_slot');
2701
2910
  const availability = deliveryField?.dateAvailability;
2702
2911
 
2912
+ // The clock. Without it leadTimeMinutes/cutoffTime/maxDaysAhead are SKIPPED and
2913
+ // the picker offers days the server refuses; `now` defaults to this instant.
2914
+ const clock = { timezone };
2915
+
2703
2916
  // Disable days on your calendar of choice. Note the SECOND condition: once
2704
2917
  // businessHours has any entry it is an ALLOWLIST, so a weekday it doesn't
2705
2918
  // mention is closed all day even though the calendar rules accept it.
2706
2919
  const isDayDisabled = (ymd: string) =>
2707
- !isCalendarDateAllowed(ymd, availability) ||
2920
+ !isCalendarDateAllowed(ymd, availability, clock) ||
2708
2921
  (!!availability?.businessHours?.length &&
2709
- getBusinessHoursForDate(availability, ymd).length === 0);
2922
+ getBusinessHoursForDate(availability, ymd, clock).length === 0);
2710
2923
 
2711
2924
  // Once the customer picks a day, offer times. computeAvailableSlots() returns
2712
2925
  // [] when the field has no slotDurationMinutes — that is NOT "day closed",
2713
- // which is why the windows are checked separately.
2714
- const slots = computeAvailableSlots(availability, '2026-08-15'); // ["09:00", "09:30", ...]
2715
- const windows = getBusinessHoursForDate(availability, '2026-08-15'); // [{ weekday, open, close }]
2926
+ // which is why the windows are checked separately. A day with two windows
2927
+ // (mornings and evenings) yields both, in chronological order.
2928
+ const slots = computeAvailableSlots(availability, '2026-08-15', clock); // ["09:00", "09:30", ...]
2929
+ const windows = getBusinessHoursForDate(availability, '2026-08-15', clock); // [{ weekday, open, close }]
2716
2930
 
2717
2931
  if (slots.length) {
2718
2932
  // Render slot buttons; the submitted time must equal a slot start exactly.
@@ -2736,6 +2950,19 @@ What you read back is normalized, not the string you sent: `YYYY-MM-DD` for
2736
2950
  `DATE`, an ISO-8601 UTC instant for `DATETIME`. Use `parseDateFieldValue()` if
2737
2951
  you want to apply the exact same parse client-side before submitting.
2738
2952
 
2953
+ **Relative bounds.** `leadTimeMinutes` puts the earliest bookable moment at
2954
+ `now + leadTime`. `cutoffTime` ("HH:mm", store-local) pushes the earliest
2955
+ bookable DATE on by a further day once the store clock reaches it, which is how
2956
+ "order by 14:00 for tomorrow" is expressed. `maxDaysAhead` is a rolling ceiling
2957
+ counted from today. All three are re-resolved on every call, so unlike an
2958
+ absolute `minDate` they never go stale, and all three apply to plain `DATE`
2959
+ fields as well. They are accepted on **checkout** custom fields only: a product
2960
+ metafield and an order custom field are written by an admin rather than picked
2961
+ by a shopper, so there is no ordering moment to measure them from and the API
2962
+ rejects them there. `resolveRelativeBounds(availability, clock)` returns the
2963
+ concrete dates they currently mean, which is what to show a shopper who asked
2964
+ for something too soon.
2965
+
2739
2966
  The backend independently re-validates every submitted value against the same
2740
2967
  constraints at write time — this is a client-side UX aid, not the source of
2741
2968
  enforcement.
@@ -2818,6 +3045,7 @@ The shipping flow involves setting an address and then selecting from available
2818
3045
  ```typescript
2819
3046
  // Step 1: Set shipping address - this returns available rates
2820
3047
  const { checkout, rates } = await client.setShippingAddress(checkoutId, {
3048
+ email: 'customer@example.com', // required
2821
3049
  firstName: 'John',
2822
3050
  lastName: 'Doe',
2823
3051
  line1: '123 Main St',
@@ -3481,12 +3709,24 @@ if (intent.clientSdk?.renderType === 'sandbox') {
3481
3709
  > characters" produces a 400 the shopper cannot explain, and render the server's
3482
3710
  > message verbatim when one comes back.
3483
3711
 
3712
+ > **Birthday fields.** `birthMonth` (1-12) and `birthDay` (1-31) are optional,
3713
+ > month and day only, never a year. Send both or neither: one on its own is
3714
+ > rejected with HTTP 400, and so is a day the month does not have. When
3715
+ > `getStoreInfo().requireBirthday` is true the merchant made the birthday
3716
+ > mandatory on that sales channel and a register call without it fails with
3717
+ > HTTP 400. That flag only reaches sales-channel mode (`salesChannelId`); a
3718
+ > `storeId`-mode storefront never receives it and its register route never
3719
+ > enforces it.
3720
+
3484
3721
  ```typescript
3485
3722
  const auth = await client.registerCustomer({
3486
3723
  email: 'customer@example.com',
3487
3724
  password: 'SecurePass123!',
3488
3725
  firstName: 'John',
3489
3726
  lastName: 'Doe',
3727
+ // Optional, unless getStoreInfo().requireBirthday is true. Both or neither.
3728
+ birthMonth: 4,
3729
+ birthDay: 17,
3490
3730
  });
3491
3731
 
3492
3732
  // Check if email verification is required
@@ -3557,6 +3797,12 @@ console.log(profile.firstName);
3557
3797
  console.log(profile.email);
3558
3798
  console.log(profile.addresses);
3559
3799
 
3800
+ // Birthday the customer saved: month and day only, never a year. Both fields
3801
+ // arrive together or neither does, so testing one is enough.
3802
+ if (profile.birthMonth && profile.birthDay) {
3803
+ console.log(`Birthday: ${profile.birthDay}/${profile.birthMonth}`);
3804
+ }
3805
+
3560
3806
  // profile.role is a free-form segment the merchant sets from the dashboard
3561
3807
  // (e.g. "wholesale", "vip") — not customer-editable. Use it to gate custom
3562
3808
  // storefront features/UI: wholesale pricing, a VIP section, etc.
@@ -3565,6 +3811,30 @@ if (profile.role === 'wholesale') {
3565
3811
  }
3566
3812
  ```
3567
3813
 
3814
+ #### Update Customer Profile
3815
+
3816
+ Storefront or vibe-coded mode, requires `customerToken`. The call returns the
3817
+ saved `CustomerProfile`, so re-render the form from the response instead of
3818
+ from what you sent. `email` and `role` are not customer-editable and are not
3819
+ accepted here.
3820
+
3821
+ ```typescript
3822
+ const updated = await client.updateMyProfile({
3823
+ firstName: 'John',
3824
+ lastName: 'Doe',
3825
+ phone: '+15550100',
3826
+ acceptsMarketing: true,
3827
+ // Birthday: month and day only, never a year. Send both or neither, and the
3828
+ // day has to exist in the month (day 31 in February is rejected with 400).
3829
+ birthMonth: 4,
3830
+ birthDay: 17,
3831
+ });
3832
+
3833
+ // The saved birthday comes back on the response and on getMyProfile(), so the
3834
+ // profile form shows what the customer stored instead of two empty fields.
3835
+ console.log(updated.birthMonth, updated.birthDay);
3836
+ ```
3837
+
3568
3838
  #### Get Customer Orders
3569
3839
 
3570
3840
  ```typescript
@@ -3646,10 +3916,15 @@ await client.registerCustomer({ email, password, referralCode: refFromQuery });
3646
3916
  // bonus (held through the program's pending window, like order points).
3647
3917
  ```
3648
3918
 
3649
- Birthday gifts need no SDK calls beyond profile data: set the customer's
3650
- `birthMonth`/`birthDay` (1-12 / 1-31, no year) via `updateMyProfile()` and the
3651
- platform emails a one-time gift coupon ahead of their birthday automatically
3652
- (when the store has it enabled).
3919
+ Birthday gifts need no SDK calls beyond profile data: save the customer's
3920
+ `birthMonth`/`birthDay` (1-12 / 1-31, month and day only, never a year) via
3921
+ `updateMyProfile()` and the platform emails a one-time gift coupon ahead of
3922
+ their birthday automatically (when the store has it enabled). Both values come
3923
+ back on `getMyProfile()`, on `getCheckoutPrefillData()` and on the `Customer`
3924
+ read types, so a profile form renders the birthday the customer already gave
3925
+ you rather than an empty pair of fields. `registerCustomer()` accepts the same
3926
+ two fields, and a channel with `requireBirthday` turned on insists on them at
3927
+ signup.
3653
3928
 
3654
3929
  #### Paid Loyalty Membership
3655
3930
 
@@ -3741,6 +4016,8 @@ const auth = await client.registerCustomer({
3741
4016
  email: 'customer@example.com',
3742
4017
  password: 'SecurePass123!',
3743
4018
  firstName: 'John',
4019
+ // Add birthMonth + birthDay here too when getStoreInfo().requireBirthday is
4020
+ // true, or this call fails with HTTP 400 before any email is sent.
3744
4021
  });
3745
4022
 
3746
4023
  if (auth.requiresVerification) {
@@ -5146,12 +5423,17 @@ is what stops an API key from escalating its own team permissions. Pointing the
5146
5423
  the correct path would earn a `403` instead of a `404`. Invite, re-scope and remove
5147
5424
  members in the dashboard.
5148
5425
 
5149
- > **The older account-level methods are not the workaround.** `getTeamMembers`,
5426
+ > **The older account-level methods are not a substitute for this.** `getTeamMembers`,
5150
5427
  > `getTeamInvitations`, `inviteTeamMember`, `resendTeamInvitation`, `revokeTeamInvitation`,
5151
5428
  > `updateTeamMemberRole` and `removeTeamMember` do still reach `/api/v1/team/…` — but they
5152
- > manage the **account** team, not a store's, and all seven are `@deprecated`. Their JSDoc
5153
- > tells you to migrate to the store-level methods named above; ignore that advice, because
5154
- > those methods 404. Don't build new integrations on either family.
5429
+ > manage the **account** team, not a store's. They will not invite anyone to a store or
5430
+ > scope a member to a sales channel; only the dashboard does that.
5431
+ >
5432
+ > **For the account team, they remain the supported call.** All seven are tagged
5433
+ > `@deprecated`, which records an intent to retire them — not a migration you can perform
5434
+ > today. There is no API-key replacement: the store-level methods named above are
5435
+ > dashboard-only. Keep using these until an API-key route ships, and expect the tag to
5436
+ > outlive this note.
5155
5437
 
5156
5438
  ### Email Settings & Templates
5157
5439
 
@@ -5314,6 +5596,56 @@ await client.detachModifierGroup(storeId, productId, attachment.id);
5314
5596
 
5315
5597
  `null` on an override means "inherit from the group default"; any non-null value (including `0` or `false`) wins. `modifierGroupId` and `variantId` are immutable on `updateAttachment` — to swap a group, detach and re-attach.
5316
5598
 
5599
+ ### Review Moderation
5600
+
5601
+ Five admin methods for moderating customer reviews and the photos attached to
5602
+ them. Listing needs an API key with `reviews:read`; hiding and showing need
5603
+ `reviews:write`. The storefront methods are covered under [Product Reviews](#product-reviews).
5604
+
5605
+ `storeId` is optional on every call: pass it when the key can reach more than
5606
+ one store.
5607
+
5608
+ ```typescript
5609
+ // Every review on the product, including the hidden ones the storefront omits.
5610
+ // visibility: 'visible' | 'hidden' | 'all' (default 'all').
5611
+ const { data, meta } = await client.adminListProductReviews('prod_123', {
5612
+ page: 1,
5613
+ limit: 20,
5614
+ visibility: 'hidden',
5615
+ storeId, // optional
5616
+ });
5617
+
5618
+ // ProductReviewAdmin carries the PII the storefront type does not:
5619
+ // customerId, authorEmail, orderId, updatedAt, hiddenAt, plus `images` typed as
5620
+ // ProductReviewImageAdmin[] (assetKey, approvedAt, hiddenAt, createdAt).
5621
+ data.forEach((review) => {
5622
+ console.log(review.authorEmail, review.orderId, review.hiddenAt);
5623
+ });
5624
+ ```
5625
+
5626
+ ```typescript
5627
+ // Hide a whole review, then put it back. Hiding stamps hiddenAt; showing clears it.
5628
+ await client.hideProductReview('rev_123', storeId);
5629
+ await client.showProductReview('rev_123', storeId);
5630
+ ```
5631
+
5632
+ ```typescript
5633
+ // Photo-level moderation: take down ONE photo and leave the review, its rating
5634
+ // and its other photos live.
5635
+ await client.hideProductReviewImage('revimg_123', storeId);
5636
+
5637
+ // Showing a photo is ALSO the approve action. A photo that has never been
5638
+ // approved carries `approvedAt: null`, and showing it stamps one, so stores that
5639
+ // turned on review-photo approval need no separate verb.
5640
+ await client.showProductReviewImage('revimg_123', storeId);
5641
+ ```
5642
+
5643
+ > **Reviews publish immediately.** There is no pending state for the review text
5644
+ > itself, so a moderation queue built on "approve each review" has nothing to
5645
+ > read. Photos are the only part that can wait on the merchant, and only on
5646
+ > stores that turned approval on. Poll `adminListProductReviews(productId, { visibility: 'all' })`
5647
+ > and treat any image with `approvedAt: null` as the queue.
5648
+
5317
5649
  ---
5318
5650
 
5319
5651
  ## Complete Page Examples
@@ -5726,12 +6058,14 @@ export default function CheckoutPage() {
5726
6058
  const [checkout, setCheckout] = useState<Checkout | null>(null);
5727
6059
  const [shippingRates, setShippingRates] = useState<ShippingRate[]>([]);
5728
6060
  const [selectedRate, setSelectedRate] = useState<string | null>(null);
6061
+ // Two phases: collect the address, then let the shopper pick a delivery option.
6062
+ const [step, setStep] = useState<'address' | 'shipping'>('address');
5729
6063
  const customerLoggedIn = isLoggedIn();
5730
6064
 
5731
- // Form state
5732
- const [email, setEmail] = useState('');
6065
+ // Form state. `email` lives here because setShippingAddress requires it on
6066
+ // every call, logged-in shoppers included.
5733
6067
  const [shippingAddress, setShippingAddress] = useState({
5734
- firstName: '', lastName: '', line1: '', city: '', postalCode: '', country: 'US'
6068
+ email: '', firstName: '', lastName: '', line1: '', city: '', postalCode: '', country: 'US'
5735
6069
  });
5736
6070
 
5737
6071
  useEffect(() => {
@@ -5761,7 +6095,8 @@ export default function CheckoutPage() {
5761
6095
  initCheckout();
5762
6096
  }, []);
5763
6097
 
5764
- const handleSubmit = async (e: React.FormEvent) => {
6098
+ // Phase 1: save the customer and the address, then show the rate picker.
6099
+ const handleAddressSubmit = async (e: React.FormEvent) => {
5765
6100
  e.preventDefault();
5766
6101
  if (!checkout) return;
5767
6102
  setSubmitting(true);
@@ -5769,19 +6104,33 @@ export default function CheckoutPage() {
5769
6104
  try {
5770
6105
  // 1. Set customer info
5771
6106
  await client.setCheckoutCustomer(checkout.id, {
5772
- email,
6107
+ email: shippingAddress.email,
5773
6108
  firstName: shippingAddress.firstName,
5774
6109
  lastName: shippingAddress.lastName,
5775
6110
  });
5776
6111
 
5777
- // 2. Set shipping address
5778
- await client.setShippingAddress(checkout.id, shippingAddress);
6112
+ // 2. Set shipping address. The same call returns the rates for that
6113
+ // address, so store them instead of fetching a second time.
6114
+ const { rates } = await client.setShippingAddress(checkout.id, shippingAddress);
6115
+ setShippingRates(rates);
6116
+ setSelectedRate(rates[0]?.id ?? null); // preselect, the shopper can change it
6117
+ setStep('shipping');
6118
+ } catch (error) {
6119
+ console.error('Could not price shipping:', error);
6120
+ alert('We could not load shipping options for that address.');
6121
+ } finally {
6122
+ setSubmitting(false);
6123
+ }
6124
+ };
5779
6125
 
5780
- // 3. Get and select shipping rate
5781
- const rates = await client.getShippingRates(checkout.id);
5782
- if (rates.length > 0) {
5783
- await client.selectShippingMethod(checkout.id, selectedRate || rates[0].id);
5784
- }
6126
+ // Phase 2: the shopper has actually chosen a rate, so persist it and complete.
6127
+ const handlePlaceOrder = async () => {
6128
+ if (!checkout || !selectedRate) return;
6129
+ setSubmitting(true);
6130
+
6131
+ try {
6132
+ // 3. Persist the chosen rate
6133
+ await client.selectShippingMethod(checkout.id, selectedRate);
5785
6134
 
5786
6135
  // 4. Complete checkout
5787
6136
  const { orderId } = await client.completeCheckout(checkout.id);
@@ -5800,28 +6149,42 @@ export default function CheckoutPage() {
5800
6149
 
5801
6150
  if (loading) return <div>Loading checkout...</div>;
5802
6151
 
6152
+ if (step === 'shipping') {
6153
+ return (
6154
+ <div>
6155
+ <h2>Delivery</h2>
6156
+ {shippingRates.length === 0 ? (
6157
+ <p>We cannot ship to that address. Please go back and edit it.</p>
6158
+ ) : (
6159
+ <select value={selectedRate || ''} onChange={(e) => setSelectedRate(e.target.value)}>
6160
+ {shippingRates.map((rate) => (
6161
+ // speedTier for live carrier rates; the merchant's own name for zone rates
6162
+ <option key={rate.id} value={rate.id}>{rate.speedTier ? TIER_LABELS[rate.speedTier] : rate.name} - ${rate.price}</option>
6163
+ ))}
6164
+ </select>
6165
+ )}
6166
+
6167
+ <button type="button" onClick={() => setStep('address')} disabled={submitting}>Edit address</button>
6168
+ <button type="button" onClick={handlePlaceOrder} disabled={submitting || !selectedRate}>
6169
+ {submitting ? 'Processing...' : 'Place Order'}
6170
+ </button>
6171
+ </div>
6172
+ );
6173
+ }
6174
+
5803
6175
  return (
5804
- <form onSubmit={handleSubmit}>
5805
- {!customerLoggedIn && (
5806
- <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" required />
5807
- )}
6176
+ <form onSubmit={handleAddressSubmit}>
6177
+ {/* Always collect the email. setShippingAddress rejects a blank one, and
6178
+ it is validated before any server-side lookup of the logged-in profile. */}
6179
+ <input type="email" value={shippingAddress.email} onChange={(e) => setShippingAddress({...shippingAddress, email: e.target.value})} placeholder="Email" required />
5808
6180
  <input value={shippingAddress.firstName} onChange={(e) => setShippingAddress({...shippingAddress, firstName: e.target.value})} placeholder="First Name" required />
5809
6181
  <input value={shippingAddress.lastName} onChange={(e) => setShippingAddress({...shippingAddress, lastName: e.target.value})} placeholder="Last Name" required />
5810
6182
  <input value={shippingAddress.line1} onChange={(e) => setShippingAddress({...shippingAddress, line1: e.target.value})} placeholder="Address" required />
5811
6183
  <input value={shippingAddress.city} onChange={(e) => setShippingAddress({...shippingAddress, city: e.target.value})} placeholder="City" required />
5812
6184
  <input value={shippingAddress.postalCode} onChange={(e) => setShippingAddress({...shippingAddress, postalCode: e.target.value})} placeholder="Postal Code" required />
5813
6185
 
5814
- {shippingRates.length > 0 && (
5815
- <select value={selectedRate || ''} onChange={(e) => setSelectedRate(e.target.value)}>
5816
- {shippingRates.map((rate) => (
5817
- // speedTier for live carrier rates; the merchant's own name for zone rates
5818
- <option key={rate.id} value={rate.id}>{rate.speedTier ? TIER_LABELS[rate.speedTier] : rate.name} - ${rate.price}</option>
5819
- ))}
5820
- </select>
5821
- )}
5822
-
5823
6186
  <button type="submit" disabled={submitting}>
5824
- {submitting ? 'Processing...' : 'Place Order'}
6187
+ {submitting ? 'Loading delivery options...' : 'Continue to delivery'}
5825
6188
  </button>
5826
6189
  </form>
5827
6190
  );
@@ -5831,6 +6194,9 @@ export default function CheckoutPage() {
5831
6194
  > **Key Points:**
5832
6195
  >
5833
6196
  > - Both guests and logged-in users go through `createCheckout()` → `completeCheckout()`
6197
+ > - `setShippingAddress()` returns the rates for the address it just saved. Put them in state; a separate `getShippingRates()` call is not needed.
6198
+ > - Split the page into two phases. Fetching rates and completing the order in one submit means the shopper never gets to choose, and you silently charge whichever rate came back first.
6199
+ > - `email` is required on `setShippingAddress`, for logged-in shoppers too. It is validated before any service code runs, so the server cannot fill it in from the customer record.
5834
6200
  > - Guest session cart is created automatically by `smart*` methods
5835
6201
  > - Call `client.onCheckoutComplete()` after successful payment to clear the session cart
5836
6202
  > - Call `client.syncCartOnLogin()` when a user logs in to merge their guest cart
@@ -5900,6 +6266,7 @@ export default function CheckoutPage() {
5900
6266
  setSubmitting(true);
5901
6267
  try {
5902
6268
  const { rates } = await client.setShippingAddress(checkout.id, {
6269
+ email, // required on every call, even after setCheckoutCustomer
5903
6270
  firstName, lastName,
5904
6271
  line1: address,
5905
6272
  city, postalCode, country,
@@ -6082,6 +6449,8 @@ export default function RegisterPage() {
6082
6449
  setLoading(true);
6083
6450
  setError('');
6084
6451
  try {
6452
+ // Add birthMonth + birthDay to this call (both, never a year) when
6453
+ // getStoreInfo().requireBirthday is true for the channel.
6085
6454
  const auth = await client.registerCustomer({ email, password, firstName, lastName });
6086
6455
 
6087
6456
  // Check if email verification is required
@@ -6183,6 +6552,9 @@ export default function AccountPage() {
6183
6552
  <h2 className="text-xl font-bold mb-4">Profile</h2>
6184
6553
  <p><strong>Name:</strong> {profile.firstName} {profile.lastName}</p>
6185
6554
  <p><strong>Email:</strong> {profile.email}</p>
6555
+ {profile.birthMonth && profile.birthDay && (
6556
+ <p><strong>Birthday:</strong> {profile.birthDay}/{profile.birthMonth}</p>
6557
+ )}
6186
6558
  </div>
6187
6559
 
6188
6560
  <div className="border rounded p-6">
@@ -6304,6 +6676,89 @@ const forms = await brainerce.contactForms.list();
6304
6676
 
6305
6677
  **Rate limit:** 3 submissions per 60 seconds per IP. Include a hidden honeypot field (and do not submit it) — bots that auto-fill every input will be rejected.
6306
6678
 
6679
+ **A form keyed `newsletter` is still an inquiry.** The key is a label, not a behaviour: the submission files a message and never touches marketing consent, so that address can never receive a campaign. For a mailing list, use [Newsletter Signup](#newsletter-signup-marketing-opt-in).
6680
+
6681
+ ---
6682
+
6683
+ ## Newsletter Signup (marketing opt-in)
6684
+
6685
+ **SDK >= 1.60.** The email-capture popup, the footer subscribe bar, the exit-intent modal.
6686
+
6687
+ ```typescript
6688
+ await brainerce.marketing.subscribe({
6689
+ email: 'jane@example.com',
6690
+ locale: 'he', // language of the confirmation email
6691
+ source: 'popup', // free-form, for the merchant's reporting
6692
+ honeypot: hiddenFieldValue, // must be empty
6693
+ });
6694
+ // → { ok: true }
6695
+ ```
6696
+
6697
+ Also accepts `firstName`, `lastName`, and `sourceMetadata` (referrer, UTM params, the page the popup fired on).
6698
+
6699
+ **⛔ It does not subscribe anyone.** The contact is created and mailed a confirmation link; the address is unmailable — and invisible to every campaign audience — until the recipient clicks it. Render **"Check your email to confirm — including your spam folder"** on success, never "You're subscribed". The spam-folder half matters: a confirmation filtered there is the commonest reason a signup never converts, and the 24-hour resend cooldown means no second copy arrives. Single opt-in is not available: without the click, anyone could subscribe anyone else's address.
6700
+
6701
+ **⛔ The response carries no information.** `{ ok: true }` is returned identically for a brand-new address, one that confirmed months ago, one inside its 24-hour resend cooldown, and one suppressed after a hard bounce — otherwise the form would become a way to test who shops at this store. Show one message for every success; there is no branch to write.
6702
+
6703
+ **Rate limit:** 3 requests per 60 seconds per IP, plus one confirmation email per address per store per 24 hours. A submission inside that cooldown still returns `{ ok: true }` and silently sends nothing — do not treat it as a failure or retry it.
6704
+
6705
+ **Locale:** pass it on a multi-language storefront, or the confirmation email falls back to the store's language. `he` and `en` are written; anything else gets English.
6706
+
6707
+ **No discount code is minted.** For a "10% off your first order" popup, the merchant creates one coupon with the `customer_first_order` condition and you display that fixed code after a successful call.
6708
+
6709
+ The contact appears at `Customers` in the dashboard immediately, with **Accepts marketing** off; it flips on at confirmation. It is an ordinary guest customer record — no password, no account — and is the same row if that person later registers or checks out.
6710
+
6711
+ ---
6712
+
6713
+ ## Back-in-Stock Alerts
6714
+
6715
+ **SDK >= 1.61.** The "email me when this is back" button on a sold-out product.
6716
+
6717
+ ```typescript
6718
+ await brainerce.stockAlerts.subscribe({
6719
+ email: 'jane@example.com',
6720
+ productId: product.id,
6721
+ variantId: selectedVariant.id, // pass on ANY product with variants
6722
+ locale: 'he', // language of the alert email
6723
+ honeypot: hiddenFieldValue, // must be empty
6724
+ });
6725
+ // → { ok: true }
6726
+ ```
6727
+
6728
+ **⛔ It is not a subscription.** One email, about one item, carrying a link that stops it. No customer account is created and no marketing consent is granted. Label the button **"Email me when it's back"**, never "Subscribe" — and because it grants no consent, never hide it from a shopper who unsubscribed from your marketing.
6729
+
6730
+ **⛔ Render it only when `getStoreInfo().stockAlertsEnabled !== false`, the item is out of stock, AND it cannot be backordered.** Requests for anything else — a storefront whose merchant switched the feature off, an in-stock item, a backorderable one, an untracked one, an unknown product id — are silently ignored, so a button in the wrong place looks like it worked and does nothing.
6731
+
6732
+ ```typescript
6733
+ const store = await brainerce.getStoreInfo();
6734
+ // `inv` is the SELECTED VARIANT's inventory when there is one, else the product's.
6735
+ const inv = selectedVariant?.inventory ?? product.inventory;
6736
+
6737
+ const canOfferStockAlert =
6738
+ store.stockAlertsEnabled !== false &&
6739
+ inv?.trackingMode === 'TRACKED' &&
6740
+ !inv.canPurchase &&
6741
+ (inv.backorderMode ?? 'NONE') === 'NONE';
6742
+ ```
6743
+
6744
+ `backorderMode` is on `InventoryInfo` from SDK 1.61; older backends omit it, so treat `undefined` as `'NONE'`.
6745
+
6746
+ The merchant controls the switch — and how many people are emailed per unit restocked — under **Channel settings → Inventory**, alongside the low-stock warning.
6747
+
6748
+ **⛔ Pass `variantId` on every variable product.** Without it the alert waits on the product as a whole, so a shopper who wanted the medium is mailed when the small returns and arrives to find their size still gone.
6749
+
6750
+ **⛔ The response carries no information.** `{ ok: true }` is returned identically for a new request, a duplicate, an unknown product, an item already in stock, and an address suppressed after a hard bounce — otherwise the button would become a way to read the store's stock levels. Show one message for every success; there is no branch to write.
6751
+
6752
+ **Sending is not immediate, and not to everyone.** Availability is `total - reserved`, so an expiring cart briefly lifts a sold-out item above zero; the alert waits for stock to hold for a few minutes, then goes out in waves sized to the units that came back (500 waiting and 3 units restocked is roughly 9 emails, oldest request first). A shopper can therefore sit through a restock without hearing, so never promise "you'll be the first to know".
6753
+
6754
+ **Rate limit:** 5 requests per 60 seconds per IP, at most 25 open alerts per address per store, and a 90-day life on an unfired alert. A duplicate request is a no-op, not a second alert; once an alert has fired the person can ask again the next time that item sells out.
6755
+
6756
+ **Locale:** pass it on a multi-language storefront, or the alert falls back to the store's language. `he` and `en` are written; anything else gets English.
6757
+
6758
+ **What it does not do:** no SMS or WhatsApp, no price-drop alerts, and no merchant-editable template — the body is fixed so it can never start carrying a discount code, which would turn a transactional message into a marketing one needing an unsubscribe link it does not have.
6759
+
6760
+ The merchant reads the demand at `Products → Back-in-Stock Waitlist`: products ranked by how many people are waiting, with the addresses behind each number. There is no way to mail those people anything else from there, by design.
6761
+
6307
6762
  ---
6308
6763
 
6309
6764
  ## Storefront Bot (AI chat widget)
@@ -6383,16 +6838,62 @@ export async function POST(req: Request) {
6383
6838
 
6384
6839
  ### Webhook Events
6385
6840
 
6386
- | Event | Description |
6387
- | -------------------- | ------------------------------- |
6388
- | `product.created` | New product created |
6389
- | `product.updated` | Product details changed |
6390
- | `product.deleted` | Product removed |
6391
- | `inventory.updated` | Stock levels changed |
6392
- | `order.created` | New order received |
6393
- | `order.updated` | Order status changed |
6394
- | `cart.abandoned` | Cart abandoned (no activity) |
6395
- | `checkout.completed` | Checkout completed successfully |
6841
+ **These 21 event types are what a subscription can actually register.** The
6842
+ backend validates the `events` array on create against exactly this list, so
6843
+ anything outside it is rejected rather than silently accepted.
6844
+
6845
+ | Event | Description |
6846
+ | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
6847
+ | `order.created` | New order placed (any payment status) |
6848
+ | `order.updated` | Order metadata changed (status, address, items) |
6849
+ | `order.paid` | Order is paid — provider capture **or** a merchant-recorded out-of-band payment (cash on delivery, bank transfer). Never assume a provider was involved; `payment.succeeded` does **not** fire for these |
6850
+ | `order.fulfilled` | All items marked shipped/delivered |
6851
+ | `order.cancelled` | Order cancelled (by merchant or customer) |
6852
+ | `order.refunded` | Order fully or partially refunded |
6853
+ | `customer.created` | New customer account created |
6854
+ | `customer.updated` | Customer profile or contact details changed |
6855
+ | `customer.deleted` | Customer account deleted |
6856
+ | `product.created` | New product added to catalog |
6857
+ | `product.updated` | Product attributes, variants, or pricing changed |
6858
+ | `product.deleted` | Product removed from catalog |
6859
+ | `inventory.updated` | Stock level changed (any reason) |
6860
+ | `inventory.low` | Stock fell below the low-stock threshold |
6861
+ | `checkout.completed` | Checkout completed (synonym of `order.created` for now) |
6862
+ | `checkout.abandoned` | Cart inactive for 1+ hours with no completion |
6863
+ | `payment.succeeded` | Payment provider confirmed funds captured |
6864
+ | `payment.failed` | Payment provider rejected the transaction |
6865
+ | `payment.refunded` | Refund posted to the customer |
6866
+ | `blog.post.published` | Post went live (manual, scheduled, or SEO Autopilot) |
6867
+ | `blog.post.updated` | Published post content changed |
6868
+
6869
+ Payload shapes for each are in the
6870
+ [Event Catalogue](https://brainerce.com/docs/webhooks/events).
6871
+
6872
+ `customer.created` now fires for **shopper self-signup on your storefront**, not
6873
+ just merchant-created customers, so a storefront that registers customers will
6874
+ start seeing it.
6875
+
6876
+ > **⚠️ The `WebhookEventType` type does not match this table yet — in both
6877
+ > directions.** Treat the table, not the type, as the truth about what you can
6878
+ > subscribe to.
6879
+ >
6880
+ > **14 subscribable events are missing from the type:** `order.paid`,
6881
+ > `order.fulfilled`, `order.cancelled`, `order.refunded`, `customer.created`,
6882
+ > `customer.updated`, `customer.deleted`, `inventory.low`, `checkout.abandoned`,
6883
+ > `payment.succeeded`, `payment.failed`, `payment.refunded`,
6884
+ > `blog.post.published`, `blog.post.updated`. So
6885
+ > `isWebhookEventType(event, 'customer.created')` and a
6886
+ > `createWebhookHandler({ 'order.paid': … })` key **fail to compile**, even
6887
+ > though both deliver correctly at runtime. Cast the name
6888
+ > (`'customer.created' as WebhookEventType`) or read `event.event` as a
6889
+ > `string` and switch on it yourself. Do not conclude the event does not exist.
6890
+ >
6891
+ > **8 names in the type cannot be subscribed to at all:** `coupon.created`,
6892
+ > `coupon.updated`, `coupon.deleted`, `cart.created`, `cart.updated`,
6893
+ > `cart.abandoned`, `checkout.started`, `checkout.failed`. These compile
6894
+ > cleanly and then fail at subscription time. `cart.abandoned` in particular
6895
+ > was listed as a supported event here for a long time — use
6896
+ > `checkout.abandoned` instead.
6396
6897
 
6397
6898
  ---
6398
6899