brainerce 1.59.0 → 1.63.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
@@ -62,13 +62,14 @@ Every Brainerce storefront must include **all mandatory features** below. Featur
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
69
  | Global header: cart count + search autocomplete | `client.getCart()`, `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
 
@@ -177,10 +179,12 @@ These sequences are non-negotiable. The order of SDK calls matters.
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)
@@ -1907,7 +1911,8 @@ fields.forEach((field) => {
1907
1911
  // field.minLength, field.maxLength: validation for text fields
1908
1912
  // field.minValue, field.maxValue: validation for number fields
1909
1913
  // field.dateAvailability: constraints for DATE/DATETIME fields (blocked
1910
- // weekdays/dates, min/max date, business hours + slots) — see
1914
+ // weekdays/dates, min/max date, leadTimeMinutes/cutoffTime/maxDaysAhead,
1915
+ // business hours + slots) — see
1911
1916
  // computeAvailableSlots()/getBusinessHoursForDate()/isDateValueAllowed()
1912
1917
  // below. DATE values are sent as "YYYY-MM-DD"; DATETIME as one ISO-8601
1913
1918
  // value — never a date with a slot LABEL glued on ("...T13:00-14:00" is
@@ -2681,7 +2686,8 @@ const updatedCheckout = await client.setCheckoutCustomFields(checkoutId, {
2681
2686
  **DATE / DATETIME fields with availability constraints**
2682
2687
 
2683
2688
  A `DATE`/`DATETIME` field's `dateAvailability` (blocked weekdays, blocked specific
2684
- dates, min/max date range, and for `DATETIME` business hours + time
2689
+ dates, min/max date range, the relative bounds `leadTimeMinutes` / `cutoffTime` /
2690
+ `maxDaysAhead`, and — for `DATETIME` — business hours + time
2685
2691
  slots) is a merchant-configured restriction on which values the customer may
2686
2692
  pick. Use `computeAvailableSlots()` / `getBusinessHoursForDate()` /
2687
2693
  `isDateValueAllowed()` to drive your own date-picker/slot-picker UI — the SDK
@@ -2700,19 +2706,24 @@ const { timezone } = await client.getStoreInfo(); // IANA string, e.g. "Asia/Jer
2700
2706
  const deliveryField = fields.find((f) => f.key === 'delivery_slot');
2701
2707
  const availability = deliveryField?.dateAvailability;
2702
2708
 
2709
+ // The clock. Without it leadTimeMinutes/cutoffTime/maxDaysAhead are SKIPPED and
2710
+ // the picker offers days the server refuses; `now` defaults to this instant.
2711
+ const clock = { timezone };
2712
+
2703
2713
  // Disable days on your calendar of choice. Note the SECOND condition: once
2704
2714
  // businessHours has any entry it is an ALLOWLIST, so a weekday it doesn't
2705
2715
  // mention is closed all day even though the calendar rules accept it.
2706
2716
  const isDayDisabled = (ymd: string) =>
2707
- !isCalendarDateAllowed(ymd, availability) ||
2717
+ !isCalendarDateAllowed(ymd, availability, clock) ||
2708
2718
  (!!availability?.businessHours?.length &&
2709
- getBusinessHoursForDate(availability, ymd).length === 0);
2719
+ getBusinessHoursForDate(availability, ymd, clock).length === 0);
2710
2720
 
2711
2721
  // Once the customer picks a day, offer times. computeAvailableSlots() returns
2712
2722
  // [] 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 }]
2723
+ // which is why the windows are checked separately. A day with two windows
2724
+ // (mornings and evenings) yields both, in chronological order.
2725
+ const slots = computeAvailableSlots(availability, '2026-08-15', clock); // ["09:00", "09:30", ...]
2726
+ const windows = getBusinessHoursForDate(availability, '2026-08-15', clock); // [{ weekday, open, close }]
2716
2727
 
2717
2728
  if (slots.length) {
2718
2729
  // Render slot buttons; the submitted time must equal a slot start exactly.
@@ -2736,6 +2747,19 @@ What you read back is normalized, not the string you sent: `YYYY-MM-DD` for
2736
2747
  `DATE`, an ISO-8601 UTC instant for `DATETIME`. Use `parseDateFieldValue()` if
2737
2748
  you want to apply the exact same parse client-side before submitting.
2738
2749
 
2750
+ **Relative bounds.** `leadTimeMinutes` puts the earliest bookable moment at
2751
+ `now + leadTime`. `cutoffTime` ("HH:mm", store-local) pushes the earliest
2752
+ bookable DATE on by a further day once the store clock reaches it, which is how
2753
+ "order by 14:00 for tomorrow" is expressed. `maxDaysAhead` is a rolling ceiling
2754
+ counted from today. All three are re-resolved on every call, so unlike an
2755
+ absolute `minDate` they never go stale, and all three apply to plain `DATE`
2756
+ fields as well. They are accepted on **checkout** custom fields only: a product
2757
+ metafield and an order custom field are written by an admin rather than picked
2758
+ by a shopper, so there is no ordering moment to measure them from and the API
2759
+ rejects them there. `resolveRelativeBounds(availability, clock)` returns the
2760
+ concrete dates they currently mean, which is what to show a shopper who asked
2761
+ for something too soon.
2762
+
2739
2763
  The backend independently re-validates every submitted value against the same
2740
2764
  constraints at write time — this is a client-side UX aid, not the source of
2741
2765
  enforcement.
@@ -3481,12 +3505,24 @@ if (intent.clientSdk?.renderType === 'sandbox') {
3481
3505
  > characters" produces a 400 the shopper cannot explain, and render the server's
3482
3506
  > message verbatim when one comes back.
3483
3507
 
3508
+ > **Birthday fields.** `birthMonth` (1-12) and `birthDay` (1-31) are optional,
3509
+ > month and day only, never a year. Send both or neither: one on its own is
3510
+ > rejected with HTTP 400, and so is a day the month does not have. When
3511
+ > `getStoreInfo().requireBirthday` is true the merchant made the birthday
3512
+ > mandatory on that sales channel and a register call without it fails with
3513
+ > HTTP 400. That flag only reaches sales-channel mode (`salesChannelId`); a
3514
+ > `storeId`-mode storefront never receives it and its register route never
3515
+ > enforces it.
3516
+
3484
3517
  ```typescript
3485
3518
  const auth = await client.registerCustomer({
3486
3519
  email: 'customer@example.com',
3487
3520
  password: 'SecurePass123!',
3488
3521
  firstName: 'John',
3489
3522
  lastName: 'Doe',
3523
+ // Optional, unless getStoreInfo().requireBirthday is true. Both or neither.
3524
+ birthMonth: 4,
3525
+ birthDay: 17,
3490
3526
  });
3491
3527
 
3492
3528
  // Check if email verification is required
@@ -3557,6 +3593,12 @@ console.log(profile.firstName);
3557
3593
  console.log(profile.email);
3558
3594
  console.log(profile.addresses);
3559
3595
 
3596
+ // Birthday the customer saved: month and day only, never a year. Both fields
3597
+ // arrive together or neither does, so testing one is enough.
3598
+ if (profile.birthMonth && profile.birthDay) {
3599
+ console.log(`Birthday: ${profile.birthDay}/${profile.birthMonth}`);
3600
+ }
3601
+
3560
3602
  // profile.role is a free-form segment the merchant sets from the dashboard
3561
3603
  // (e.g. "wholesale", "vip") — not customer-editable. Use it to gate custom
3562
3604
  // storefront features/UI: wholesale pricing, a VIP section, etc.
@@ -3565,6 +3607,30 @@ if (profile.role === 'wholesale') {
3565
3607
  }
3566
3608
  ```
3567
3609
 
3610
+ #### Update Customer Profile
3611
+
3612
+ Storefront or vibe-coded mode, requires `customerToken`. The call returns the
3613
+ saved `CustomerProfile`, so re-render the form from the response instead of
3614
+ from what you sent. `email` and `role` are not customer-editable and are not
3615
+ accepted here.
3616
+
3617
+ ```typescript
3618
+ const updated = await client.updateMyProfile({
3619
+ firstName: 'John',
3620
+ lastName: 'Doe',
3621
+ phone: '+15550100',
3622
+ acceptsMarketing: true,
3623
+ // Birthday: month and day only, never a year. Send both or neither, and the
3624
+ // day has to exist in the month (day 31 in February is rejected with 400).
3625
+ birthMonth: 4,
3626
+ birthDay: 17,
3627
+ });
3628
+
3629
+ // The saved birthday comes back on the response and on getMyProfile(), so the
3630
+ // profile form shows what the customer stored instead of two empty fields.
3631
+ console.log(updated.birthMonth, updated.birthDay);
3632
+ ```
3633
+
3568
3634
  #### Get Customer Orders
3569
3635
 
3570
3636
  ```typescript
@@ -3646,10 +3712,15 @@ await client.registerCustomer({ email, password, referralCode: refFromQuery });
3646
3712
  // bonus (held through the program's pending window, like order points).
3647
3713
  ```
3648
3714
 
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).
3715
+ Birthday gifts need no SDK calls beyond profile data: save the customer's
3716
+ `birthMonth`/`birthDay` (1-12 / 1-31, month and day only, never a year) via
3717
+ `updateMyProfile()` and the platform emails a one-time gift coupon ahead of
3718
+ their birthday automatically (when the store has it enabled). Both values come
3719
+ back on `getMyProfile()`, on `getCheckoutPrefillData()` and on the `Customer`
3720
+ read types, so a profile form renders the birthday the customer already gave
3721
+ you rather than an empty pair of fields. `registerCustomer()` accepts the same
3722
+ two fields, and a channel with `requireBirthday` turned on insists on them at
3723
+ signup.
3653
3724
 
3654
3725
  #### Paid Loyalty Membership
3655
3726
 
@@ -3741,6 +3812,8 @@ const auth = await client.registerCustomer({
3741
3812
  email: 'customer@example.com',
3742
3813
  password: 'SecurePass123!',
3743
3814
  firstName: 'John',
3815
+ // Add birthMonth + birthDay here too when getStoreInfo().requireBirthday is
3816
+ // true, or this call fails with HTTP 400 before any email is sent.
3744
3817
  });
3745
3818
 
3746
3819
  if (auth.requiresVerification) {
@@ -5146,12 +5219,17 @@ is what stops an API key from escalating its own team permissions. Pointing the
5146
5219
  the correct path would earn a `403` instead of a `404`. Invite, re-scope and remove
5147
5220
  members in the dashboard.
5148
5221
 
5149
- > **The older account-level methods are not the workaround.** `getTeamMembers`,
5222
+ > **The older account-level methods are not a substitute for this.** `getTeamMembers`,
5150
5223
  > `getTeamInvitations`, `inviteTeamMember`, `resendTeamInvitation`, `revokeTeamInvitation`,
5151
5224
  > `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.
5225
+ > manage the **account** team, not a store's. They will not invite anyone to a store or
5226
+ > scope a member to a sales channel; only the dashboard does that.
5227
+ >
5228
+ > **For the account team, they remain the supported call.** All seven are tagged
5229
+ > `@deprecated`, which records an intent to retire them — not a migration you can perform
5230
+ > today. There is no API-key replacement: the store-level methods named above are
5231
+ > dashboard-only. Keep using these until an API-key route ships, and expect the tag to
5232
+ > outlive this note.
5155
5233
 
5156
5234
  ### Email Settings & Templates
5157
5235
 
@@ -6082,6 +6160,8 @@ export default function RegisterPage() {
6082
6160
  setLoading(true);
6083
6161
  setError('');
6084
6162
  try {
6163
+ // Add birthMonth + birthDay to this call (both, never a year) when
6164
+ // getStoreInfo().requireBirthday is true for the channel.
6085
6165
  const auth = await client.registerCustomer({ email, password, firstName, lastName });
6086
6166
 
6087
6167
  // Check if email verification is required
@@ -6183,6 +6263,9 @@ export default function AccountPage() {
6183
6263
  <h2 className="text-xl font-bold mb-4">Profile</h2>
6184
6264
  <p><strong>Name:</strong> {profile.firstName} {profile.lastName}</p>
6185
6265
  <p><strong>Email:</strong> {profile.email}</p>
6266
+ {profile.birthMonth && profile.birthDay && (
6267
+ <p><strong>Birthday:</strong> {profile.birthDay}/{profile.birthMonth}</p>
6268
+ )}
6186
6269
  </div>
6187
6270
 
6188
6271
  <div className="border rounded p-6">
@@ -6304,6 +6387,89 @@ const forms = await brainerce.contactForms.list();
6304
6387
 
6305
6388
  **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
6389
 
6390
+ **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).
6391
+
6392
+ ---
6393
+
6394
+ ## Newsletter Signup (marketing opt-in)
6395
+
6396
+ **SDK >= 1.60.** The email-capture popup, the footer subscribe bar, the exit-intent modal.
6397
+
6398
+ ```typescript
6399
+ await brainerce.marketing.subscribe({
6400
+ email: 'jane@example.com',
6401
+ locale: 'he', // language of the confirmation email
6402
+ source: 'popup', // free-form, for the merchant's reporting
6403
+ honeypot: hiddenFieldValue, // must be empty
6404
+ });
6405
+ // → { ok: true }
6406
+ ```
6407
+
6408
+ Also accepts `firstName`, `lastName`, and `sourceMetadata` (referrer, UTM params, the page the popup fired on).
6409
+
6410
+ **⛔ 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.
6411
+
6412
+ **⛔ 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.
6413
+
6414
+ **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.
6415
+
6416
+ **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.
6417
+
6418
+ **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.
6419
+
6420
+ 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.
6421
+
6422
+ ---
6423
+
6424
+ ## Back-in-Stock Alerts
6425
+
6426
+ **SDK >= 1.61.** The "email me when this is back" button on a sold-out product.
6427
+
6428
+ ```typescript
6429
+ await brainerce.stockAlerts.subscribe({
6430
+ email: 'jane@example.com',
6431
+ productId: product.id,
6432
+ variantId: selectedVariant.id, // pass on ANY product with variants
6433
+ locale: 'he', // language of the alert email
6434
+ honeypot: hiddenFieldValue, // must be empty
6435
+ });
6436
+ // → { ok: true }
6437
+ ```
6438
+
6439
+ **⛔ 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.
6440
+
6441
+ **⛔ 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.
6442
+
6443
+ ```typescript
6444
+ const store = await brainerce.getStoreInfo();
6445
+ // `inv` is the SELECTED VARIANT's inventory when there is one, else the product's.
6446
+ const inv = selectedVariant?.inventory ?? product.inventory;
6447
+
6448
+ const canOfferStockAlert =
6449
+ store.stockAlertsEnabled !== false &&
6450
+ inv?.trackingMode === 'TRACKED' &&
6451
+ !inv.canPurchase &&
6452
+ (inv.backorderMode ?? 'NONE') === 'NONE';
6453
+ ```
6454
+
6455
+ `backorderMode` is on `InventoryInfo` from SDK 1.61; older backends omit it, so treat `undefined` as `'NONE'`.
6456
+
6457
+ The merchant controls the switch — and how many people are emailed per unit restocked — under **Channel settings → Inventory**, alongside the low-stock warning.
6458
+
6459
+ **⛔ 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.
6460
+
6461
+ **⛔ 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.
6462
+
6463
+ **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".
6464
+
6465
+ **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.
6466
+
6467
+ **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.
6468
+
6469
+ **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.
6470
+
6471
+ 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.
6472
+
6307
6473
  ---
6308
6474
 
6309
6475
  ## Storefront Bot (AI chat widget)
@@ -6383,16 +6549,62 @@ export async function POST(req: Request) {
6383
6549
 
6384
6550
  ### Webhook Events
6385
6551
 
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 |
6552
+ **These 21 event types are what a subscription can actually register.** The
6553
+ backend validates the `events` array on create against exactly this list, so
6554
+ anything outside it is rejected rather than silently accepted.
6555
+
6556
+ | Event | Description |
6557
+ | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
6558
+ | `order.created` | New order placed (any payment status) |
6559
+ | `order.updated` | Order metadata changed (status, address, items) |
6560
+ | `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 |
6561
+ | `order.fulfilled` | All items marked shipped/delivered |
6562
+ | `order.cancelled` | Order cancelled (by merchant or customer) |
6563
+ | `order.refunded` | Order fully or partially refunded |
6564
+ | `customer.created` | New customer account created |
6565
+ | `customer.updated` | Customer profile or contact details changed |
6566
+ | `customer.deleted` | Customer account deleted |
6567
+ | `product.created` | New product added to catalog |
6568
+ | `product.updated` | Product attributes, variants, or pricing changed |
6569
+ | `product.deleted` | Product removed from catalog |
6570
+ | `inventory.updated` | Stock level changed (any reason) |
6571
+ | `inventory.low` | Stock fell below the low-stock threshold |
6572
+ | `checkout.completed` | Checkout completed (synonym of `order.created` for now) |
6573
+ | `checkout.abandoned` | Cart inactive for 1+ hours with no completion |
6574
+ | `payment.succeeded` | Payment provider confirmed funds captured |
6575
+ | `payment.failed` | Payment provider rejected the transaction |
6576
+ | `payment.refunded` | Refund posted to the customer |
6577
+ | `blog.post.published` | Post went live (manual, scheduled, or SEO Autopilot) |
6578
+ | `blog.post.updated` | Published post content changed |
6579
+
6580
+ Payload shapes for each are in the
6581
+ [Event Catalogue](https://brainerce.com/docs/webhooks/events).
6582
+
6583
+ `customer.created` now fires for **shopper self-signup on your storefront**, not
6584
+ just merchant-created customers, so a storefront that registers customers will
6585
+ start seeing it.
6586
+
6587
+ > **⚠️ The `WebhookEventType` type does not match this table yet — in both
6588
+ > directions.** Treat the table, not the type, as the truth about what you can
6589
+ > subscribe to.
6590
+ >
6591
+ > **14 subscribable events are missing from the type:** `order.paid`,
6592
+ > `order.fulfilled`, `order.cancelled`, `order.refunded`, `customer.created`,
6593
+ > `customer.updated`, `customer.deleted`, `inventory.low`, `checkout.abandoned`,
6594
+ > `payment.succeeded`, `payment.failed`, `payment.refunded`,
6595
+ > `blog.post.published`, `blog.post.updated`. So
6596
+ > `isWebhookEventType(event, 'customer.created')` and a
6597
+ > `createWebhookHandler({ 'order.paid': … })` key **fail to compile**, even
6598
+ > though both deliver correctly at runtime. Cast the name
6599
+ > (`'customer.created' as WebhookEventType`) or read `event.event` as a
6600
+ > `string` and switch on it yourself. Do not conclude the event does not exist.
6601
+ >
6602
+ > **8 names in the type cannot be subscribed to at all:** `coupon.created`,
6603
+ > `coupon.updated`, `coupon.deleted`, `cart.created`, `cart.updated`,
6604
+ > `cart.abandoned`, `checkout.started`, `checkout.failed`. These compile
6605
+ > cleanly and then fail at subscription time. `cart.abandoned` in particular
6606
+ > was listed as a supported event here for a long time — use
6607
+ > `checkout.abandoned` instead.
6396
6608
 
6397
6609
  ---
6398
6610