@drawbridge/drawbridge-utils 0.0.168 → 0.0.169

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.
@@ -1,7 +1,7 @@
1
1
  import { authToken } from './oauth.cjs';
2
2
  export { consentUrl, pkcePair } from './oauth.cjs';
3
3
  import { toE164, detectCountry } from '../phone.cjs';
4
- import crypto, { createHmac, timingSafeEqual, createVerify, createHash, randomUUID } from 'node:crypto';
4
+ import crypto, { randomUUID, createHmac, timingSafeEqual, createVerify, createHash } from 'node:crypto';
5
5
  import { request } from '../http.cjs';
6
6
  import { channels } from '../pricing.cjs';
7
7
  import { customAlphabet } from 'nanoid';
@@ -186,7 +186,20 @@ const HOOKS = Object.freeze({
186
186
 
187
187
  sms : Object.freeze([ 'send' ]),
188
188
 
189
- segment : Object.freeze([ 'sync' ]),
189
+ segment : Object.freeze([
190
+ // Make this segment's object exist at the vendor, carrying the segment's
191
+ // current title, and describe the row that points at it. IDEMPOTENT: the
192
+ // same call creates it, renames it after an edit, and backfills a segment
193
+ // that predates the connection — so one hook serves every path and there
194
+ // is no create-vs-update branch to keep in step.
195
+ 'register',
196
+ // Remove the vendor object this connection's row points at. Called with
197
+ // the pre-image on a segment delete, because by then the document is gone.
198
+ 'remove',
199
+ // Recalculate Drawbridge-side membership. Private to the drawbridge
200
+ // manifest; a vendor does not own who is in a Drawbridge segment.
201
+ 'sync'
202
+ ]),
190
203
 
191
204
  // OUTBOUND DELIVERY to an address the merchant owns, rather than to a vendor.
192
205
  // The Webhooks connection is the only thing here with no third party behind
@@ -516,6 +529,8 @@ const STEPS = Object.freeze({
516
529
  'email.digest' : 'Digest',
517
530
  'email.notify' : 'Notification',
518
531
  'email.send' : 'Send email',
532
+ 'segment.register' : 'Register segment',
533
+ 'segment.remove' : 'Remove segment',
519
534
  'segment.sync' : 'Sync segment',
520
535
  'sms.send' : 'Send SMS',
521
536
  'webhook.send' : 'Send webhook'
@@ -715,7 +730,16 @@ const tokenSettings = ({ existing = {}, now = Date.now(), tokens }) => ({
715
730
  ...( tokens.expiresIn && {
716
731
  expiresAt : new Date( now + ( tokens.expiresIn * 1000 ) ).toISOString()
717
732
  }),
718
- ...( tokens.scope && { scope : tokens.scope })
733
+ // A VENDOR NEED NOT RETURN `scope` ON A REFRESH. Klaviyo documents it on the
734
+ // authorization_code response and documents no response body at all for the
735
+ // refresh grant, so taking the minted value alone drops the stored one. That
736
+ // matters because `scope` is load-bearing: the segment hooks gate on
737
+ // `segments:write` and answer `skipped` when it is absent, so a connection
738
+ // that dropped it disables its whole segment half without failing anything
739
+ // and shows a reconnect task that reconnecting has already fixed.
740
+ ...( ( tokens.scope || existing.scope ) && {
741
+ scope : tokens.scope || existing.scope
742
+ })
719
743
  });
720
744
 
721
745
  // Returns a token that is good right now, refreshing and persisting first if the
@@ -792,6 +816,176 @@ var icon$4 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xm
792
816
  <path d="M166.04 261.805C180.228 259.107 195.528 261.893 207.581 269.971C218.512 277.079 226.908 288.136 230.604 300.657C234.835 314.103 233.614 329.124 227.485 341.788C220.097 356.875 205.782 368.543 189.317 372.089C173.957 375.652 157.089 372.386 144.304 363.103C132.971 355.295 124.912 342.989 121.891 329.581C118.943 316.636 120.725 302.656 127.002 290.938C134.699 275.951 149.503 264.933 166.046 261.811" fill="#1E1C1C"/>
793
817
  </svg>`;
794
818
 
819
+ // WHERE A DRAWBRIDGE SEGMENT WENT, one row per connection on the segment
820
+ // document. The row is not membership — membership stays as each vendor keeps
821
+ // it, as tags or properties or members — it says which vendor object stands for
822
+ // this segment, and where a merchant can open it.
823
+ //
824
+ // Shared rather than repeated in three manifests because the upsert is the
825
+ // subtle part: two descriptors, each a no-op when the other applies, so a
826
+ // register that changes nothing writes nothing and two concurrent registers
827
+ // converge on ONE ROW rather than two.
828
+ //
829
+ // THE ROW, NOT THE VENDOR OBJECT BEHIND IT. Each manifest searches by name
830
+ // before it creates, which NARROWS the window rather than closing it: two
831
+ // concurrent first registers can both search, both miss and both create, and a
832
+ // drift re-queue carries a deliberately unique job id precisely so it can run
833
+ // alongside a fresh dispatch. What the pair below guarantees is that only one of
834
+ // them is ever pointed at — the loser is an orphaned vendor object, which a
835
+ // merchant can see and delete, and not a row pointing at the wrong thing.
836
+
837
+ // NO `env` HERE, unlike Shopify's urls.manage( data, env ). That third argument
838
+ // is supplied by the api, which has one; a segment url is built inside a hook,
839
+ // and `env` is not in HOOK_PROPS — so a vendor reaching for it would receive
840
+ // undefined on every run. Threading a parameter nothing can fill is a trap for
841
+ // whoever writes the next manifest.
842
+ const row = ({ connection, data, manifest, row : described }) => ({
843
+ connection : connection?.id,
844
+ // ALWAYS A STRING. Mailchimp tag ids are integers, Klaviyo segment ids are
845
+ // strings, and one type in the schema is one comparison in the $or below.
846
+ id : String( described?.id ),
847
+ slug : connection?.slug,
848
+ type : described?.type,
849
+ // NULL, NEVER UNDEFINED: undefined drops the key, and the schema requires it.
850
+ // The url is built HERE, while the settings are decrypted and the vendor
851
+ // facts are in hand — an api reading the row later has neither.
852
+ url : manifest?.urls?.segment?.( { ...described, id : String( described?.id ) }, data ) || null
853
+ });
854
+
855
+ const segmentRowWrites = ({ connection, data, manifest, row : described, segment }) => {
856
+
857
+ const built = row({ connection, data, manifest, row : described });
858
+
859
+ return [
860
+ // PUSH IF ABSENT. The $ne guard is what makes a second concurrent register
861
+ // add nothing rather than a duplicate row for one connection.
862
+ {
863
+ collection : 'segment',
864
+ data : { $push : { connections : built } },
865
+ operation : 'update',
866
+ query : {
867
+ id : segment?.id,
868
+ 'connections.connection' : { $ne : connection?.id }
869
+ }
870
+ },
871
+ // SET IF DIFFERENT. $elemMatch selects this connection's row only when one
872
+ // of its three mutable fields disagrees, so the steady state — the same
873
+ // vendor object, the same url — matches nothing and writes nothing.
874
+ {
875
+ collection : 'segment',
876
+ data : { $set : { 'connections.$' : built } },
877
+ operation : 'update',
878
+ query : {
879
+ id : segment?.id,
880
+ connections : {
881
+ $elemMatch : {
882
+ connection : connection?.id,
883
+ $or : [
884
+ { id : { $ne : built.id } },
885
+ { type : { $ne : built.type } },
886
+ { url : { $ne : built.url } }
887
+ ]
888
+ }
889
+ }
890
+ }
891
+ }
892
+ ];
893
+
894
+ };
895
+
896
+ const segmentRowRemoveWrites = ({ connection, segment }) => [
897
+ {
898
+ collection : 'segment',
899
+ data : { $pull : { connections : { connection : connection?.id } } },
900
+ operation : 'update',
901
+ query : { id : segment?.id }
902
+ }
903
+ ];
904
+
905
+ // THE ROW THIS CONNECTION ALREADY HAS, or undefined. The segments the shell
906
+ // hands a hook carry `connections`, so a register can answer "nothing to do"
907
+ // without a read.
908
+ const segmentRowFor = ({ connection, segment }) => ( segment?.connections || [] )
909
+ .find( ( entry ) => entry?.connection === connection?.id );
910
+
911
+ // THE SEGMENT AS IT IS NOW, not as it was when this run was dispatched.
912
+ //
913
+ // Four places dispatch register under ONE job id, which is what stops four
914
+ // dispatches becoming four vendor round-trips — but it also means the run
915
+ // carries whichever trigger data won the race. Re-reading is how a coalesced
916
+ // run applies the current title rather than a stale one.
917
+ //
918
+ // Falls back to the passed document when no reader was injected. `read` is an
919
+ // optional capability rather than a guaranteed one — a caller invoking runHook
920
+ // without a database reader still gets a working hook, it just applies the
921
+ // document it was handed.
922
+ const currentSegment = async ({ read, segment }) => {
923
+
924
+ if( ! read?.get || ! segment?.id ) return segment;
925
+
926
+ return read.get({
927
+ collection : 'segment',
928
+ query : { id : segment.id }
929
+ });
930
+
931
+ };
932
+
933
+ // ONE MORE PASS WHEN THE TITLE MOVED UNDER US.
934
+ //
935
+ // A rename that arrives while a register is ACTIVE is dropped, because the
936
+ // coalescing job id still exists — so the vendor object would keep the old name
937
+ // forever. Re-reading at the end catches it, and the re-queue carries a UNIQUE
938
+ // id, since the coalescing one is what dropped the rename in the first place.
939
+ //
940
+ // THE TIMESTAMP AND THE RANDOM SUFFIX ARE BOTH LOAD-BEARING. The timestamp is
941
+ // for a person reading the queue, who needs to see when a drift pass was
942
+ // raised; the random half is what actually makes the id unique, because two
943
+ // drift re-queues for one segment inside the same millisecond would otherwise
944
+ // collide back into a single job — dropping the very rename this exists to
945
+ // recover.
946
+ //
947
+ // ponytail: NO `priority`, unlike every dispatch that goes through
948
+ // drawbridge-sync's dispatchExecute — which computes one per organization so a
949
+ // tenant spiking past its window is deprioritized behind everyone else. A drift
950
+ // re-queue therefore enters at BullMQ's default and jumps that queue. Not
951
+ // fixable by taking a `priority` argument either: the number comes from an
952
+ // in-process counter in sync that this package cannot see and that a manifest
953
+ // hook is never handed. Closing it means sync stamping a priority onto described
954
+ // enqueues in step-runner's perform(), which is a change to every enqueue every
955
+ // hook describes — worth doing when a second caller wants it, not for one
956
+ // re-queue that fires only on a rename that landed mid-register.
957
+ const driftEnqueues = async ({ applied, read, segment, workflow }) => {
958
+
959
+ // NOTHING TO RE-QUEUE WITHOUT A WORKFLOW. The enqueue names the workflow to
960
+ // execute and keys its job id on the same id, so an absent one queues a job
961
+ // carrying workflowId : undefined under an id holding the literal string
962
+ // "undefined" — a job that cannot run and that collides with every other
963
+ // workflowless drift pass.
964
+ if( ! workflow?.id ) return [];
965
+
966
+ const fresh = await currentSegment({ read, segment });
967
+
968
+ if( ! fresh?.id || fresh.title === applied ) return [];
969
+
970
+ return [ {
971
+ data : {
972
+ triggerData : {
973
+ organization : fresh.organization || workflow?.organization,
974
+ segment : fresh
975
+ },
976
+ workflowId : workflow?.id
977
+ },
978
+ name : 'execute',
979
+ options : {
980
+ jobId : 'workflow.insert.execute.' + workflow?.id + '.segment.register.' + fresh.id + '.drift.' + Date.now() + '.' + randomUUID().slice( 0, 8 ),
981
+ removeOnComplete : true,
982
+ removeOnFail : true
983
+ },
984
+ queue : 'workflow'
985
+ } ];
986
+
987
+ };
988
+
795
989
  // ONE REQUEST SHAPE for every Attentive call, the way Klaviyo's file has one.
796
990
  // The path carries its own version — the segments picker is v2 and the
797
991
  // subscription writes are v1 — because Attentive versions per resource rather
@@ -908,7 +1102,7 @@ var attentive = {
908
1102
  // has to say so rather than let them believe otherwise.
909
1103
  confirm : 'Disconnecting removes Drawbridge\'s stored Attentive token. Attentive does not offer a way for us to revoke it, so remove the Drawbridge integration in Attentive as well if you want its access fully withdrawn. Your subscribers stay in both Attentive and Drawbridge — neither list is deleted.',
910
1104
  description : [
911
- 'Attentive is where your SMS marketing lives, and this connection syncs the contacts your campaigns collect into an Attentive segment — subscribed for marketing and added to the segment you choose.',
1105
+ 'This connection syncs the contacts your campaigns collect into your Attentive account — subscribed for marketing, and added to the segment you choose.',
912
1106
  'You authorize Drawbridge from inside Attentive and can revoke that access there at any time. Drawbridge never sees or stores your Attentive password.',
913
1107
  'Anyone who has opted out in Drawbridge is sent to Attentive as an unsubscribe rather than omitted, so a person who asked not to be contacted stays suppressed in both systems instead of quietly reappearing.',
914
1108
  'Attentive accepts these updates and applies them in the background, so a contact appears in your segment shortly after the sync rather than the instant it runs.'
@@ -950,10 +1144,9 @@ var attentive = {
950
1144
  }
951
1145
  ],
952
1146
  group : 'contacts',
953
- // A HOOK'S VALUE IS ITS ANSWER. The consent is stored and can be revoked;
954
- // nothing else is built yet, because subscriber sync has not shipped. Every
955
- // false here is "not yet" rather than "never" — when the sync lands, probe
956
- // and contacts.sync are the first to flip.
1147
+ // WHAT THIS VENDOR DOES AND DOES NOT DO is the value of each hook below, not
1148
+ // a paragraph up here that goes stale the moment one of them is implemented
1149
+ // which is exactly what happened to the note this replaces.
957
1150
  hooks : {
958
1151
 
959
1152
  auth : {
@@ -967,7 +1160,7 @@ var attentive = {
967
1160
  // nobody re-derives it. Klaviyo's connect reads the account name back so
968
1161
  // the card is not blank; Attentive's card stays blank. There IS an
969
1162
  // endpoint — GET https://api.attentivemobile.com/v1/me, Bearer, described
970
- // on docs.attentive.com/pages/authentication/ as returning "information
1163
+ // on docs.attentive.com/docs/authentication as returning "information
971
1164
  // specific to your company" — but its RESPONSE SCHEMA is published
972
1165
  // nowhere we can read: the docs show the curl and no body. Reading
973
1166
  // `body.name` would be a guess, and a guess here fails at the worst
@@ -1187,7 +1380,71 @@ var attentive = {
1187
1380
  promotions : false
1188
1381
 
1189
1382
  },
1190
- segment : false,
1383
+ segment : {
1384
+
1385
+ // A FOUNDATION, AND HONEST ABOUT IT. Attentive's segments API can create
1386
+ // one with an externalId we choose (docs.attentive.com/reference/
1387
+ // createsegment.md, fetched 2026-09-11: POST /v2/segments, `name`
1388
+ // required, `externalId` optional and "auto-generated if not supplied"),
1389
+ // which would give a real per-segment object — but it takes
1390
+ // segments:write, and scopes ride on the app registration, which does not
1391
+ // exist yet.
1392
+ //
1393
+ // So the row points at the connection-level segment the merchant chose,
1394
+ // `type` says so, and turning this into a per-segment object later is a
1395
+ // change to this file and nothing else: create with
1396
+ // externalId = segment.id, PATCH to rename, archive on remove. Their
1397
+ // update and archive endpoints are BOTH keyed by external id
1398
+ // (docs.attentive.com/reference/patchsegmentbyexternalid.md and
1399
+ // /deletesegmentbyexternalid.md, fetched 2026-09-11), so the segment id we
1400
+ // already hold addresses every one of the three calls.
1401
+ //
1402
+ // NO DRIFT CHECK, unlike the other two: the row points at the
1403
+ // connection's own segment and the link is the index page, so nothing
1404
+ // here depends on the Drawbridge segment's title — a rename has nothing
1405
+ // to apply and nothing to race with. That comes back with the
1406
+ // per-segment object.
1407
+ //
1408
+ // THE RE-READ STAYS ALL THE SAME. It changes nothing today, and this is
1409
+ // the simplest of the three registers and therefore the one the next
1410
+ // vendor gets copied from — one job id serves four dispatch sites, so a
1411
+ // copy that trusts context.segment applies whichever trigger data won
1412
+ // the race, at a vendor where the title does matter.
1413
+ register : async ( { connection, context, manifest, settings }, { read } = {} ) => {
1414
+
1415
+ const segment = await currentSegment({ read, segment : context?.segment });
1416
+
1417
+ if( ! segment?.id || segment.system ) return { message : 'That segment is not one this connection publishes.', skipped : true };
1418
+
1419
+ if( ! settings?.segment ) return { message : 'No Attentive segment is chosen for this connection.', skipped : true };
1420
+
1421
+ return {
1422
+ events : [ {
1423
+ event : 'organization.segments',
1424
+ payload : { id : segment.id },
1425
+ room : 'organization.' + connection?.organization
1426
+ } ],
1427
+ message : 'Contacts in this segment are added to the Attentive segment chosen on this connection.',
1428
+ writes : segmentRowWrites({
1429
+ connection,
1430
+ data : { ...connection, settings },
1431
+ manifest,
1432
+ row : { id : settings.segment, type : 'segment' },
1433
+ segment
1434
+ })
1435
+ };
1436
+
1437
+ },
1438
+
1439
+ // NOT OURS TO DELETE. The segment on this connection is the merchant's,
1440
+ // and it is where every Drawbridge segment's contacts go — removing it
1441
+ // because one Drawbridge segment was deleted would empty the others.
1442
+ remove : false,
1443
+
1444
+ // Drawbridge-side membership belongs to the private manifest.
1445
+ sync : false
1446
+
1447
+ },
1191
1448
  sms : false,
1192
1449
  webhook : false
1193
1450
 
@@ -1217,6 +1474,21 @@ var attentive = {
1217
1474
  'ATTENTIVE_OAUTH_CLIENT_ID',
1218
1475
  'ATTENTIVE_OAUTH_CLIENT_SECRET'
1219
1476
  ],
1477
+ // THE CHECKLIST'S RECEIPT — see drawbridge-docs reference/connection-hooks.md.
1478
+ review : {
1479
+ api : 'https://docs.attentive.com/reference/listsegments',
1480
+ dashboard : 'https://docs.attentive.com/docs/segments',
1481
+ // THIS PAGE DOES NOT LIST EVERY SCOPE. Its table names five —
1482
+ // events:write, ecommerce:write, subscriptions:write, attributes:write,
1483
+ // privacy_requests:write — and says nothing about segments:read or
1484
+ // segments:write, which the segments API this manifest calls does take.
1485
+ // The header at the top of this file carries that distinction; it is
1486
+ // repeated here so a reviewer following the link is not misled by what the
1487
+ // table omits (fetched 2026-09-11).
1488
+ scopes : 'https://docs.attentive.com/docs/authentication',
1489
+ content : '2026-09-11',
1490
+ verified : null
1491
+ },
1220
1492
  slug : 'attentive',
1221
1493
  // A consent with no segment chosen is authenticated and inert — the sync needs
1222
1494
  // somewhere to put people — so the card says Pending rather than Active over
@@ -1263,6 +1535,27 @@ var attentive = {
1263
1535
 
1264
1536
  })
1265
1537
 
1538
+ },
1539
+
1540
+ segment : {
1541
+
1542
+ // SYSTEM, so the builder never offers it and a merchant POST refuses it:
1543
+ // this fires from the segment's own lifecycle, not from a workflow
1544
+ // somebody assembled. The trigger is declared here rather than hard-coded
1545
+ // in drawbridge-sync.
1546
+ //
1547
+ // REGISTER ONLY. There is no remove step because hooks.segment.remove is
1548
+ // declined, and build() refuses a step pointing at a hook this vendor does
1549
+ // not implement — so the two are one decision, enforced at import.
1550
+ register : () => ({
1551
+ description : 'Records which Attentive segment a Drawbridge segment\'s contacts are added to.',
1552
+ hook : 'segment.register',
1553
+ key : 'Attentive Segment Register',
1554
+ queue : 'connection',
1555
+ system : true,
1556
+ trigger : { event : 'segment.register', type : 'event' }
1557
+ })
1558
+
1266
1559
  }
1267
1560
 
1268
1561
  },
@@ -1280,7 +1573,22 @@ var attentive = {
1280
1573
  }
1281
1574
  ]
1282
1575
  ),
1283
- title : 'Attentive'
1576
+ title : 'Attentive',
1577
+
1578
+ // ATTENTIVE'S SEGMENTS INDEX. There is no documented per-segment url, and the
1579
+ // only identifier we hold is the API's externalId, which their UI may not
1580
+ // path by — so this lands on the list, where the merchant finds it by name.
1581
+ // A per-segment link arrives with the per-segment object (see hooks.segment).
1582
+ //
1583
+ // THE PATH ITSELF IS NOT PUBLISHED ANYWHERE CITABLE, the same gap Klaviyo's
1584
+ // segment url carries. What is on record is that the segments area lives at
1585
+ // ui.attentivemobile.com/segments — its /segments/create/ and /segments/manual
1586
+ // sub-routes are publicly indexed pages (fetched 2026-09-11) — while the /all
1587
+ // tab is not, and Attentive's help centre refuses automated fetches. The dev
1588
+ // walk-through confirms this against a real account before promote.
1589
+ urls : {
1590
+ segment : () => 'https://ui.attentivemobile.com/segments/all'
1591
+ }
1284
1592
  };
1285
1593
 
1286
1594
  // THE SHARED HALF OF inbound.verify.
@@ -2145,7 +2453,7 @@ var drawbridge = {
2145
2453
  content : {
2146
2454
  confirm : 'This connection is part of Drawbridge and cannot be disconnected.',
2147
2455
  description : [
2148
- 'Drawbridge sends your notification email and SMS, keeps your segments in sync, and posts to your own endpoints. These are built in rather than connected, so there is nothing here to set up.'
2456
+ 'Drawbridge sends your notification emails and your entrants\' emails, and recalculates segment membership on a schedule. It is always on and needs nothing connected.'
2149
2457
  ],
2150
2458
  excerpt : 'The steps Drawbridge runs itself.',
2151
2459
  guide : [
@@ -2591,6 +2899,12 @@ var drawbridge = {
2591
2899
  },
2592
2900
  segment : {
2593
2901
 
2902
+ // NOT DRAWBRIDGE'S. Registering a segment means creating an object at a
2903
+ // vendor, and this manifest has no vendor behind it — the three that do
2904
+ // implement these.
2905
+ register : false,
2906
+ remove : false,
2907
+
2594
2908
  // RECALCULATE SEGMENT MEMBERSHIP. The one FAN-OUT hook: it evaluates every
2595
2909
  // contact in an organization against every segment, which is too much for
2596
2910
  // one job, so it returns chunks and the shell defers completion.
@@ -2956,6 +3270,33 @@ var drawbridge = {
2956
3270
  // caught exactly that: availableConnections({}) went from [ 'drawbridge' ] to
2957
3271
  // empty the moment this was added.
2958
3272
  requires : [],
3273
+ // PRIVATE, NOT VENDORLESS. SendGrid, Twilio and HubSpot are behind this
3274
+ // manifest, so `false` would be a lie about which reads were made.
3275
+ //
3276
+ // ONE ENTRY PER VENDOR, because three vendors are three reads. A single
3277
+ // citation here would evidence one of them and read as though it covered all
3278
+ // three, which is the omission this key exists to catch.
3279
+ review : {
3280
+ api : {
3281
+ // lib/hubspot.js posts to /crm/v3/objects/contacts.
3282
+ hubspot : 'https://developers.hubspot.com/docs/reference/api/crm/objects/contacts',
3283
+ // lib/sendgrid.js posts to /v3/mail/send.
3284
+ sendgrid : 'https://www.twilio.com/docs/sendgrid/api-reference/mail-send/mail-send',
3285
+ // lib/twilio.js posts to /2010-04-01/Accounts/{Sid}/Messages.json, and
3286
+ // hooks.inbound.verify reads the MessageStatus this resource documents.
3287
+ twilio : 'https://www.twilio.com/docs/messaging/api/message-resource'
3288
+ },
3289
+ dashboard : {
3290
+ hubspot : 'https://knowledge.hubspot.com/contacts/create-contacts',
3291
+ sendgrid : 'https://www.twilio.com/docs/sendgrid/ui/analytics-and-reporting/email-activity-feed',
3292
+ twilio : 'https://www.twilio.com/docs/messaging/guides/debugging-tools'
3293
+ },
3294
+ // An admin types these keys in; there is no merchant consent and no scope
3295
+ // model on any of the three.
3296
+ scopes : false,
3297
+ content : '2026-09-11',
3298
+ verified : null
3299
+ },
2959
3300
  slug : 'drawbridge',
2960
3301
  // Always on. There is no credential that could go bad and no configuration a
2961
3302
  // merchant could leave half-finished.
@@ -3171,6 +3512,25 @@ const api$1 = async ( path, { fetcher = fetch, method = 'GET', payload, token }
3171
3512
 
3172
3513
  };
3173
3514
 
3515
+ // The segment's name at Klaviyo, spelled once. It is a LABEL — the definition
3516
+ // below keys on the id — so a rename never has to touch a profile.
3517
+ const segmentName = ( title ) => 'Drawbridge: ' + title;
3518
+
3519
+ // THE GRANT THIS NEEDS. Klaviyo's scopes are set on the app and a token carries
3520
+ // only what the merchant consented to, so a connection made before segments were
3521
+ // asked for holds one that cannot write a segment — Create, Update and Delete
3522
+ // Segment each list `segments:write`
3523
+ // (raw.githubusercontent.com/klaviyo/openapi/main/openapi/stable.json, revision
3524
+ // 2026-07-15, fetched 2026-09-11). Answering 403 three times tells the merchant
3525
+ // nothing; this does.
3526
+ //
3527
+ // `scope` is the grant Klaviyo returned on the exchange, which its OAuth guide
3528
+ // describes as "The scopes that this access token has access to for accessing
3529
+ // API resources" (developers.klaviyo.com/en/docs/set_up_oauth, fetched
3530
+ // 2026-09-11) — not the list this manifest asks for, which is why a connection
3531
+ // older than the ask reads as false rather than true.
3532
+ const canManageSegments = ( settings ) => String( settings?.scope || '' ).split( /\s+/ ).includes( 'segments:write' );
3533
+
3174
3534
  // Klaviyo — contact sync, over OAuth.
3175
3535
  //
3176
3536
  // EVERYTHING ABOUT THIS VENDOR IS IN THIS FILE. Its copy, the fields it stores,
@@ -3220,9 +3580,21 @@ var klaviyo = {
3220
3580
  // exchange, and a copy here would be a second answer that goes stale.
3221
3581
  expiry : 90 * 24 * 60 * 60,
3222
3582
  pkce : true,
3583
+ // EVERY SCOPE THE MANIFEST'S HOOKS NEED, not the ones today's hooks use.
3584
+ // Klaviyo holds scopes on the APP — "Pinpoint which scopes your app uses
3585
+ // and set them using a space-separated list"
3586
+ // (developers.klaviyo.com/en/docs/create_a_public_oauth_app, fetched
3587
+ // 2026-09-11) — and a merchant's token only ever carries what they
3588
+ // consented to, so a scope added later is a reconnect for every one of
3589
+ // them. That is what segments cost when they were left out here.
3590
+ //
3223
3591
  // Space separated. accounts:read is required by Klaviyo on every app
3224
- // and must stay in the list; the rest are what a contact sync needs.
3225
- scopes : 'accounts:read lists:read lists:write profiles:read profiles:write',
3592
+ // and must stay in the list; the rest are what a contact sync and the
3593
+ // segment hooks need — Get Segments lists `segments:read`, Create,
3594
+ // Update and Delete Segment each list `segments:write`
3595
+ // (raw.githubusercontent.com/klaviyo/openapi/main/openapi/stable.json,
3596
+ // revision 2026-07-15, fetched 2026-09-11).
3597
+ scopes : 'accounts:read lists:read lists:write profiles:read profiles:write segments:read segments:write',
3226
3598
  // EVERY VENDOR URL, in one place. `revoke` used to be a literal inside
3227
3599
  // the disconnect hook — three vendor addresses, two of them declared,
3228
3600
  // which is exactly the kind of split that goes unnoticed.
@@ -3273,9 +3645,10 @@ var klaviyo = {
3273
3645
  confirm : 'Disconnecting revokes Drawbridge\'s access to your Klaviyo account. Your profiles and lists stay in both Klaviyo and Drawbridge — neither is deleted.',
3274
3646
 
3275
3647
  description : [
3276
- 'Connecting Klaviyo lets Drawbridge sync the contacts your campaigns collect into a Klaviyo list, so the people who enter a giveaway can be marketed to alongside the rest of your audience.',
3648
+ 'Connecting Klaviyo lets Drawbridge sync the contacts your campaigns collect into a Klaviyo list, so you can market to the people who enter a giveaway alongside the rest of your audience.',
3277
3649
  'You authorize Drawbridge from inside Klaviyo and can revoke that access there at any time. Drawbridge never sees or stores your Klaviyo password, and only asks for the permissions listed on the consent screen.',
3278
- 'Anyone who has opted out in Drawbridge is synced as unsubscribed rather than omitted, so a person who asked not to be contacted stays suppressed in both systems instead of quietly reappearing.'
3650
+ 'Anyone who has opted out in Drawbridge is synced as unsubscribed rather than omitted, so a person who asked not to be contacted stays suppressed in both systems instead of quietly reappearing.',
3651
+ 'Drawbridge writes its own properties onto the profiles it syncs: how many of your campaigns someone entered, their entries, draws and orders, the revenue their orders attributed to your campaigns, and which Drawbridge segments they are in. You can build Klaviyo segments and flows on any of them.'
3279
3652
  ],
3280
3653
 
3281
3654
  // KEYED BY WHAT FAILED, not nested inside it. Errors are the thing most
@@ -3536,7 +3909,14 @@ var klaviyo = {
3536
3909
  // `segments` is null when the run carried no contact document,
3537
3910
  // meaning nobody looked — different from [], which means they
3538
3911
  // are in none. Null omits the key and merge leaves it alone.
3539
- ...( segments && { drawbridge_segments : segments.map( ( entry ) => entry.title ).filter( Boolean ) })
3912
+ ...( segments && { drawbridge_segments : segments.map( ( entry ) => entry.title ).filter( Boolean ) }),
3913
+ // THE IDS, which is what a Drawbridge-made segment's definition
3914
+ // filters on. Ids rather than titles, so renaming a segment is a
3915
+ // name change at Klaviyo and not a resync of every profile.
3916
+ //
3917
+ // The titles stay beside them: merchants have been building
3918
+ // their own segments on that array since it shipped.
3919
+ ...( segments && { drawbridge_segment_ids : segments.map( ( entry ) => entry.id ).filter( Boolean ) })
3540
3920
  }
3541
3921
  },
3542
3922
  type : 'profile'
@@ -3599,17 +3979,218 @@ var klaviyo = {
3599
3979
 
3600
3980
  },
3601
3981
 
3602
- // A WHOLE DOMAIN CAN DECLINE AT ONCE. Klaviyo sends us nothing and we
3603
- // register nothing with it, so listing four falses would be noise around a
3604
- // single decision. Still explicit absence would not say whether anybody
3605
- // considered it.
3606
- // Drawbridge sends its own notification email and SMS, and owns its own
3607
- // segments — see the private `drawbridge` manifest. A vendor answering
3608
- // these would be a second sender, which is the arrangement the platform
3609
- // sender replaced.
3982
+ // Drawbridge sends its own notification email. A vendor answering this
3983
+ // would be a second sender, which is the arrangement the platform sender
3984
+ // replaced. Declined as one line rather than one per verb, because the whole
3985
+ // domain is one decision — still explicit, since absence would not say
3986
+ // whether anybody considered it.
3610
3987
  email : false,
3611
- segment : false,
3988
+
3989
+ // SEGMENTS ARE SHARED, not owned by one side. Drawbridge holds the
3990
+ // membership — see the private `drawbridge` manifest, and `sync : false`
3991
+ // below — while register and remove keep a Klaviyo segment standing for
3992
+ // each Drawbridge segment, so the merchant can target one in their own
3993
+ // flows.
3994
+ segment : {
3995
+
3996
+ // THE KLAVIYO SEGMENT THIS DRAWBRIDGE SEGMENT BECOMES.
3997
+ //
3998
+ // Klaviyo owns no writable membership — its segments are computed from
3999
+ // rules — so the segment we create is DEFINED BY the profile property
4000
+ // contacts.sync writes. The definition filters on the Drawbridge
4001
+ // segment's ID, never its title, which is what makes a rename one PATCH
4002
+ // instead of a resync of every profile in it.
4003
+ register : async ( { connection, context, manifest, settings, token, workflow }, { fetcher, read } = {} ) => {
4004
+
4005
+ // AS IT IS NOW — see the same note on Mailchimp's. One job id serves
4006
+ // four dispatch sites, so the trigger data is whichever one won.
4007
+ const segment = await currentSegment({ read, segment : context?.segment });
4008
+
4009
+ if( ! segment?.id || segment.system ) return { message : 'That segment is not one this connection publishes.', skipped : true };
4010
+
4011
+ if( ! canManageSegments( settings ) ){
4012
+
4013
+ return {
4014
+ message : 'Reconnect Klaviyo to let Drawbridge manage segments — this connection was made before that permission was asked for.',
4015
+ skipped : true
4016
+ };
4017
+
4018
+ }
4019
+
4020
+ const name = segmentName( segment.title );
4021
+ const existing = segmentRowFor({ connection, segment });
4022
+
4023
+ let id = null;
4024
+
4025
+ if( existing?.id ){
4026
+
4027
+ try {
4028
+
4029
+ const found = await api$1( '/segments/' + existing.id, { fetcher, token });
4030
+
4031
+ id = found?.data?.id ?? existing.id;
4032
+
4033
+ if( found?.data?.attributes?.name !== name ){
4034
+
4035
+ // NAME ONLY. Update Segment takes `name` on its own —
4036
+ // nothing in its attributes is required — and the definition
4037
+ // keys on the segment id, which has not changed, so rewriting
4038
+ // it would rebuild the segment for nothing.
4039
+ await api$1( '/segments/' + existing.id, {
4040
+ fetcher,
4041
+ method : 'PATCH',
4042
+ payload : { data : { attributes : { name }, id : existing.id, type : 'segment' } },
4043
+ token
4044
+ });
4045
+
4046
+ }
4047
+
4048
+ } catch ( error ){
4049
+
4050
+ // A 404 means the merchant deleted the segment themselves — fall
4051
+ // through and rebuild rather than failing a step they caused.
4052
+ if( error.status !== 404 ) throw error;
4053
+
4054
+ id = null;
4055
+
4056
+ }
4057
+
4058
+ }
4059
+
4060
+ // BY NAME BEFORE CREATING, which NARROWS the window rather than
4061
+ // closing it. Klaviyo segment names are not unique, so two
4062
+ // overlapping registers — a contact joining seconds after the segment
4063
+ // was made, or a drift re-queue whose deliberately unique job id lets
4064
+ // it run alongside a fresh dispatch — would otherwise leave a second
4065
+ // segment nothing points at. Two concurrent FIRST registers can still
4066
+ // both search, both miss and both create; only one row survives, so
4067
+ // the loser is an orphaned segment rather than a wrong one.
4068
+ //
4069
+ // `equals` is one of the two operators Get Segments allows on `name`,
4070
+ // and a string argument is quoted: "we will accept either single
4071
+ // quoted or double-quoted strings"
4072
+ // (developers.klaviyo.com/en/docs/filtering_, fetched 2026-09-11).
4073
+ // The whole expression is URI-encoded, which the same page requires.
4074
+ //
4075
+ // THE TITLE IS ESCAPED, because it is free text a merchant types and a
4076
+ // double quote in it would otherwise close the literal early — a
4077
+ // malformed filter is a 400 the step retries three times before
4078
+ // failing with no row written. The same page gives the escape: "Single
4079
+ // or double-quoted characters within strings (quoted with like quote
4080
+ // characters) MUST be escaped with a single backslash (i.e. 'Tony\'s
4081
+ // ball')". Only the matching quote needs it, so a double-quoted
4082
+ // literal escapes double quotes and leaves apostrophes alone.
4083
+ if( ! id ){
4084
+
4085
+ const search = await api$1( '/segments?filter=' + encodeURIComponent( 'equals(name,"' + name.replace( /"/g, '\\"' ) + '")' ), { fetcher, token });
4086
+
4087
+ id = ( search?.data || [] ).find( ( entry ) => entry?.attributes?.name === name )?.id ?? null;
4088
+
4089
+ }
4090
+
4091
+ if( ! id ){
4092
+
4093
+ const created = await api$1( '/segments', {
4094
+ fetcher,
4095
+ method : 'POST',
4096
+ // THE DEFINITION IS THE MEMBERSHIP. Create Segment requires one
4097
+ // — `name` and `definition` are both required on its attributes
4098
+ // — and a custom profile property is addressed as
4099
+ // "properties['property name']", tested with a list filter whose
4100
+ // operator is `contains`
4101
+ // (raw.githubusercontent.com/klaviyo/openapi/main/openapi/stable.json,
4102
+ // revision 2026-07-15, fetched 2026-09-11).
4103
+ payload : {
4104
+ data : {
4105
+ attributes : {
4106
+ definition : {
4107
+ condition_groups : [ {
4108
+ conditions : [ {
4109
+ filter : { operator : 'contains', type : 'list', value : segment.id },
4110
+ property : 'properties[\'drawbridge_segment_ids\']',
4111
+ type : 'profile-property'
4112
+ } ]
4113
+ } ]
4114
+ },
4115
+ name
4116
+ },
4117
+ type : 'segment'
4118
+ }
4119
+ },
4120
+ token
4121
+ });
4122
+
4123
+ id = created?.data?.id;
4124
+
4125
+ }
4126
+
4127
+ if( ! id ) return { message : 'Klaviyo returned no segment id.', skipped : true };
4128
+
4129
+ return {
4130
+ // A RENAME THAT ARRIVED WHILE THIS RAN was dropped by the
4131
+ // coalescing job id, so the last thing this does is look again.
4132
+ enqueues : await driftEnqueues({ applied : segment.title, read, segment, workflow }),
4133
+ events : [ {
4134
+ event : 'organization.segments',
4135
+ payload : { id : segment.id },
4136
+ room : 'organization.' + connection?.organization
4137
+ } ],
4138
+ message : 'Klaviyo is carrying this segment as "' + name + '".',
4139
+ writes : segmentRowWrites({
4140
+ connection,
4141
+ data : { ...connection, settings },
4142
+ manifest,
4143
+ row : { id, type : 'segment' },
4144
+ segment
4145
+ })
4146
+ };
4147
+
4148
+ },
4149
+
4150
+ // NO RE-READ. The segment is already deleted; the pre-image is the only
4151
+ // copy, and it carries the row naming what to delete.
4152
+ remove : async ( { connection, context, settings, token }, { fetcher } = {} ) => {
4153
+
4154
+ const segment = context?.segment;
4155
+ const existing = segmentRowFor({ connection, segment });
4156
+
4157
+ if( ! existing?.id ) return { message : 'Klaviyo was never carrying this segment.', skipped : true };
4158
+
4159
+ if( ! canManageSegments( settings ) ){
4160
+
4161
+ return { message : 'Reconnect Klaviyo to let Drawbridge manage segments.', skipped : true };
4162
+
4163
+ }
4164
+
4165
+ try {
4166
+
4167
+ await api$1( '/segments/' + existing.id, { fetcher, method : 'DELETE', token });
4168
+
4169
+ } catch ( error ){
4170
+
4171
+ // ALREADY GONE IS DONE. The merchant may have deleted it, and
4172
+ // retrying a 404 twice more achieves nothing.
4173
+ if( error.status !== 404 ) throw error;
4174
+
4175
+ }
4176
+
4177
+ return {
4178
+ message : 'Klaviyo is no longer carrying this segment.',
4179
+ writes : segmentRowRemoveWrites({ connection, segment })
4180
+ };
4181
+
4182
+ },
4183
+
4184
+ // Drawbridge-side membership belongs to the private manifest.
4185
+ sync : false
4186
+
4187
+ },
4188
+ // Declined for the same reason as `email` above: Drawbridge sends its own
4189
+ // notification SMS, and a vendor answering this would be a second sender.
3612
4190
  sms : false,
4191
+
4192
+ // Klaviyo sends us nothing — no inbound message to receive, no signature
4193
+ // to verify.
3613
4194
  inbound : false,
3614
4195
 
3615
4196
  // Nothing to set up or tear down at the vendor: the grant is the whole
@@ -3785,6 +4366,18 @@ var klaviyo = {
3785
4366
  'KLAVIYO_OAUTH_CLIENT_ID',
3786
4367
  'KLAVIYO_OAUTH_CLIENT_SECRET'
3787
4368
  ],
4369
+ // THE CHECKLIST'S RECEIPT — see drawbridge-docs reference/connection-hooks.md.
4370
+ review : {
4371
+ api : 'https://developers.klaviyo.com/en/reference/api_overview',
4372
+ dashboard : 'https://help.klaviyo.com/hc/en-us/articles/115005078647',
4373
+ // THE SCOPE TABLE, not the OAuth walk-through. set_up_oauth carries one
4374
+ // example scope string and nothing to check a manifest against; this page
4375
+ // lists the scopes each API takes, segments:read and segments:write among
4376
+ // them (fetched 2026-09-11).
4377
+ scopes : 'https://developers.klaviyo.com/en/docs/authenticate_',
4378
+ content : '2026-09-11',
4379
+ verified : null
4380
+ },
3788
4381
  slug : 'klaviyo',
3789
4382
  // ONE OF THE FOUR STATES AND NOTHING ELSE — the reason sits in `tasks`, which
3790
4383
  // is already the merchant-facing copy channel and is already rendered.
@@ -3815,7 +4408,8 @@ var klaviyo = {
3815
4408
  hook : 'lifecycle.health',
3816
4409
  key : 'Klaviyo Connection Health',
3817
4410
  queue : 'connection',
3818
- system : true
4411
+ system : true,
4412
+ trigger : { event : 'day', type : 'schedule' }
3819
4413
  })
3820
4414
  }
3821
4415
 
@@ -3868,6 +4462,32 @@ var klaviyo = {
3868
4462
 
3869
4463
  })
3870
4464
 
4465
+ },
4466
+
4467
+ segment : {
4468
+
4469
+ // SYSTEM, so the builder never offers it and a merchant POST refuses it:
4470
+ // these fire from the segment's own lifecycle, not from a workflow
4471
+ // somebody assembled. The trigger is declared here rather than hard-coded
4472
+ // in drawbridge-sync.
4473
+ register : () => ({
4474
+ description : 'Keeps a matching Klaviyo segment for each Drawbridge segment, built on the segment ids Drawbridge writes onto your profiles.',
4475
+ hook : 'segment.register',
4476
+ key : 'Klaviyo Segment Register',
4477
+ queue : 'connection',
4478
+ system : true,
4479
+ trigger : { event : 'segment.register', type : 'event' }
4480
+ }),
4481
+
4482
+ remove : () => ({
4483
+ description : 'Deletes the Klaviyo segment for a Drawbridge segment when the segment is deleted.',
4484
+ hook : 'segment.remove',
4485
+ key : 'Klaviyo Segment Remove',
4486
+ queue : 'connection',
4487
+ system : true,
4488
+ trigger : { event : 'segment.remove', type : 'event' }
4489
+ })
4490
+
3871
4491
  }
3872
4492
 
3873
4493
  },
@@ -3876,17 +4496,43 @@ var klaviyo = {
3876
4496
  // is reconnecting — prompting "choose a list" there asks the merchant to
3877
4497
  // configure a grant that no longer exists. `pending` is exactly this task's
3878
4498
  // moment: the grant is good and the list is the missing half.
3879
- tasks : ( data ) => ( ! [ 'active', 'pending' ].includes( data?.status ) || data?.settings?.list
3880
- ? []
3881
- : [
3882
- {
4499
+ tasks : ( data ) => {
4500
+
4501
+ if( ! [ 'active', 'pending' ].includes( data?.status ) ) return [];
4502
+
4503
+ // BOTH, WHEN BOTH APPLY. These are independent facts about one connection
4504
+ // — an old grant that cannot manage segments, and a list nobody picked —
4505
+ // and returning only the first means a merchant fixes it, comes back, and
4506
+ // discovers the second. The grant leads because reconnecting is the longer
4507
+ // errand.
4508
+ return [
4509
+ // A connection made before segments were requested is authenticated and
4510
+ // cannot manage them, and no error surfaces anywhere else — the register
4511
+ // runs skip rather than fail.
4512
+ ...( canManageSegments( data?.settings ) ? [] : [ {
4513
+ message : 'Drawbridge now keeps a Klaviyo segment in step with each of your Drawbridge segments. Reconnect Klaviyo to allow it.',
4514
+ title : 'Reconnect Klaviyo'
4515
+ } ] ),
4516
+ ...( data?.settings?.list ? [] : [ {
3883
4517
  message : 'Choose which Klaviyo list your contacts should sync into. Until you do, nothing is being synced.',
3884
4518
  title : 'Choose a list'
3885
- }
3886
- ]
3887
- ),
4519
+ } ] )
4520
+ ];
4521
+
4522
+ },
3888
4523
 
3889
- title : 'Klaviyo'
4524
+ title : 'Klaviyo',
4525
+
4526
+ // KLAVIYO PUBLISHES NO DASHBOARD URLS in its API reference. What is on record
4527
+ // is its own help centre on a list: "you can find a list's ID in the URL in
4528
+ // your browser when viewing this list"
4529
+ // (help.klaviyo.com/hc/en-us/articles/115005078647, fetched 2026-09-11), and a
4530
+ // segment's page is the sibling form of it. The path itself is NOT published
4531
+ // anywhere citable, so the dev walk-through confirms this against a real
4532
+ // account before promote.
4533
+ urls : {
4534
+ segment : ( row ) => ( row?.id ? 'https://www.klaviyo.com/segment/' + row.id : null )
4535
+ }
3890
4536
  };
3891
4537
 
3892
4538
  // Mailchimp, exported from the brand kit and left as authored — the fills are the
@@ -3967,6 +4613,11 @@ const subscriberHash = ( email ) => createHash( 'md5' )
3967
4613
  .update( String( email ).trim().toLowerCase() )
3968
4614
  .digest( 'hex' );
3969
4615
 
4616
+ // THE TAG'S NAME, and the one place it is spelled. The member write and the
4617
+ // register hook must agree character for character — they address the same
4618
+ // object, one by name and one by id — so a prefix change is one edit here.
4619
+ const tagName = ( title ) => 'Drawbridge: ' + title;
4620
+
3970
4621
  // Mailchimp — contact sync, not a sender.
3971
4622
  var mailchimp = {
3972
4623
  // OAUTH 2, authorization code. Every url below is quoted from
@@ -4010,7 +4661,7 @@ var mailchimp = {
4010
4661
  confirm : 'Disconnecting removes Drawbridge\'s stored Mailchimp access. You can also remove Drawbridge from the Authorized Apps page in your Mailchimp account. Your contacts stay in both Drawbridge and Mailchimp — neither list is deleted.',
4011
4662
  description : [
4012
4663
  'Drawbridge no longer sends email through Mailchimp. Notification email now sends from Drawbridge itself, and verifying a domain under Messaging in your organization settings puts your own brand in the from line.',
4013
- 'Connecting Mailchimp lets Drawbridge sync the contacts your campaigns collect into a Mailchimp audience, so the people who enter a giveaway can be marketed to alongside the rest of your list.',
4664
+ 'Connecting Mailchimp lets Drawbridge sync the contacts your campaigns collect into a Mailchimp audience, so you can market to the people who enter a giveaway alongside the rest of your list.',
4014
4665
  'Anyone who has opted out in Drawbridge is synced as unsubscribed rather than omitted, so a person who asked not to be contacted stays suppressed in both systems instead of quietly reappearing.',
4015
4666
  'Someone who unsubscribed inside Mailchimp keeps that choice: a resync only sets the status of a subscriber Mailchimp has never seen before.'
4016
4667
  ],
@@ -4023,6 +4674,7 @@ var mailchimp = {
4023
4674
  'Sign in to Mailchimp if you are not already, and choose the account to connect.',
4024
4675
  'You come back here to pick the audience your contacts should sync into.',
4025
4676
  'The connection shows Pending until you pick an audience, then Active.',
4677
+ 'Each of your Drawbridge segments appears in that audience as a tag named "Drawbridge: " plus the segment name. Renaming a segment in Drawbridge renames its tag, and deleting the segment deletes the tag.',
4026
4678
  'You can remove Drawbridge at any time from the Authorized Apps page in your Mailchimp account.'
4027
4679
  ]
4028
4680
  },
@@ -4048,10 +4700,9 @@ var mailchimp = {
4048
4700
  }
4049
4701
  ],
4050
4702
  group : 'contacts',
4051
- // A HOOK'S VALUE IS ITS ANSWER. A key is stored and can be removed; nothing
4052
- // else is built yet, because audience sync has not shipped. Every false here
4053
- // is "not yet" rather than "never" when the sync lands, probe and
4054
- // contacts.sync are the first to flip.
4703
+ // WHAT THIS VENDOR DOES AND DOES NOT DO is the value of each hook below, not
4704
+ // a paragraph up here that goes stale the moment one of them is implemented
4705
+ // which is exactly what happened to the note this replaces.
4055
4706
  hooks : {
4056
4707
 
4057
4708
  auth : {
@@ -4135,6 +4786,45 @@ var mailchimp = {
4135
4786
 
4136
4787
  const hash = subscriberHash( email );
4137
4788
 
4789
+ // NAME AND PHONE TRAVEL AS MERGE FIELDS, because there is no other
4790
+ // way to write them. Mailchimp's member object has a `full_name`,
4791
+ // but their own schema marks it `"readOnly" : true` — it is DERIVED
4792
+ // from FNAME and LNAME
4793
+ // (api.mailchimp.com/schema/3.0/Definitions/Lists/Members/Response.json,
4794
+ // fetched 2026-09-11), so writing a whole name in one field is not
4795
+ // on offer at any price.
4796
+ //
4797
+ // ONLY THE DEFAULT TAGS. A merge tag the audience does not have is
4798
+ // refused along with the whole member request, and "merge fields
4799
+ // with the tags *|FNAME|*, *|LNAME|*, *|ADDRESS|* and *|PHONE|* are
4800
+ // present by default when an audience is created"
4801
+ // (mailchimp.com/developer/marketing/docs/merge-fields, fetched
4802
+ // 2026-09-11) — so these are the ones safe to send without
4803
+ // registering anything first.
4804
+ //
4805
+ // ADDRESS IS LEFT OUT DELIBERATELY: `lead.address` is an array of
4806
+ // free-text strings and Mailchimp's ADDRESS wants a structured
4807
+ // object (addr1/city/state/zip/country). There is no honest mapping,
4808
+ // and a malformed one takes the member request down with it.
4809
+ //
4810
+ // A lead carries ONE `name`, so the last name is everything after
4811
+ // the first whitespace run — "Ada Lovelace" splits Ada/Lovelace, and
4812
+ // a single-word name sends no LNAME rather than an empty one.
4813
+ const [ firstName, ...restOfName ] = String( lead?.name || '' ).trim().split( /\s+/ ).filter( Boolean );
4814
+
4815
+ const lastName = restOfName.join( ' ' );
4816
+
4817
+ // The raw number the entrant gave, NOT the canonical form: this is a
4818
+ // field the merchant will contact them on, and canonical values are
4819
+ // for identity matching only.
4820
+ const phone = lead?.phone?.number || null;
4821
+
4822
+ const mergeFields = {
4823
+ ...( firstName && { FNAME : firstName }),
4824
+ ...( lastName && { LNAME : lastName }),
4825
+ ...( phone && { PHONE : phone })
4826
+ };
4827
+
4138
4828
  // SUPPRESSED PEOPLE ARE SYNCED AS UNSUBSCRIBED, NEVER OMITTED.
4139
4829
  //
4140
4830
  // Omitting them means Mailchimp never learns they said no, so the
@@ -4161,14 +4851,12 @@ var mailchimp = {
4161
4851
  method : 'PUT',
4162
4852
  payload : {
4163
4853
  email_address : email,
4164
- // FNAME ONLY. Unlike Klaviyo, Mailchimp's custom fields are not
4165
- // schemaless a merge tag that does not exist on the audience is
4166
- // refused, taking the whole request with it and FNAME is one of
4167
- // the two tags every audience is created with. The Drawbridge
4168
- // totals Klaviyo receives cannot travel until something registers
4169
- // merge fields on the chosen audience, which is lifecycle.register's
4170
- // job and is not built.
4171
- ...( lead?.name && { merge_fields : { FNAME : String( lead.name ).trim().split( /\s+/ )[ 0 ] } }),
4854
+ // Built above. Omitted entirely when there is nothing to say, so a
4855
+ // lead with only an address does not send an empty object. The
4856
+ // Drawbridge totals Klaviyo receives still cannot travel this way
4857
+ // those are custom tags, and registering them on the chosen audience
4858
+ // is lifecycle.register's job and is not built.
4859
+ ...( Object.keys( mergeFields ).length > 0 && { merge_fields : mergeFields }),
4172
4860
  ...( suppressed && { status : 'unsubscribed' }),
4173
4861
  status_if_new : suppressed ? 'unsubscribed' : 'subscribed'
4174
4862
  },
@@ -4184,8 +4872,8 @@ var mailchimp = {
4184
4872
  // and is not built. A tag needs no setup: "If a tag that does not exist
4185
4873
  // is passed in and set as 'active', a new tag will be created"
4186
4874
  // (mailchimp.com/developer/marketing/api/list-member-tags/add-or-remove-member-tags,
4187
- // fetched 2026-09-09). Nothing for the merchant to prepare, so nothing
4188
- // to explain in the guide.
4875
+ // fetched 2026-09-09). Nothing for the merchant to prepare the guide
4876
+ // says what appears in their audience, not what to set up first.
4189
4877
  //
4190
4878
  // ACTIVE AND INACTIVE IN ONE CALL, which is what keeps this correct
4191
4879
  // over time. Drawbridge segments are dynamic, and nothing dispatches a
@@ -4226,7 +4914,7 @@ var mailchimp = {
4226
4914
  .map( ( entry ) => entry.title )
4227
4915
  .filter( Boolean )
4228
4916
  .map( ( title ) => ({
4229
- name : 'Drawbridge: ' + title,
4917
+ name : tagName( title ),
4230
4918
  status : joined.has( title ) ? 'active' : 'inactive'
4231
4919
  }) );
4232
4920
 
@@ -4258,12 +4946,211 @@ var mailchimp = {
4258
4946
  }
4259
4947
 
4260
4948
  },
4261
- // Drawbridge sends its own notification email and SMS, and owns its own
4262
- // segments see the private `drawbridge` manifest. A vendor answering
4263
- // these would be a second sender, which is the arrangement the platform
4264
- // sender replaced.
4949
+ // Drawbridge sends its own notification email. A vendor answering this
4950
+ // would be a second sender, which is the arrangement the platform sender
4951
+ // replaced.
4265
4952
  email : false,
4266
- segment : false,
4953
+
4954
+ // SEGMENTS ARE SHARED, not owned by one side. Drawbridge holds the
4955
+ // membership — see the private `drawbridge` manifest, and `sync : false`
4956
+ // below — while register and remove keep a Mailchimp tag standing for each
4957
+ // Drawbridge segment, so the merchant can target one in their own audience.
4958
+ segment : {
4959
+
4960
+ // THE TAG THIS SEGMENT IS, held by id at last.
4961
+ //
4962
+ // Tags ARE static segments in Mailchimp's model — same collection, same
4963
+ // ids — so this creates one through /segments and the member write goes
4964
+ // on attaching people to it by name. Both address the same object. The
4965
+ // segment schema says it outright: "The type of segment. Static segments
4966
+ // are now known as tags"
4967
+ // (api.mailchimp.com/schema/3.0/Swagger.json, fetched 2026-09-11).
4968
+ //
4969
+ // IDEMPOTENT ON EVERY PATH: called on create, on rename, on the boot
4970
+ // sweep and on backfill, it converges. That is what lets one hook serve
4971
+ // all four without a create-vs-update branch anywhere else.
4972
+ register : async ( { connection, context, manifest, settings, token, workflow }, { fetcher, read } = {} ) => {
4973
+
4974
+ const audience = settings?.audience;
4975
+
4976
+ if( ! audience ) return { message : 'No Mailchimp audience is chosen for this connection.', skipped : true };
4977
+
4978
+ // AS IT IS NOW, not as it was dispatched. Four places queue register
4979
+ // under one job id, so this run carries whichever of them won — and
4980
+ // applying that title would undo a rename that arrived after it.
4981
+ const segment = await currentSegment({ read, segment : context?.segment });
4982
+
4983
+ if( ! segment?.id || segment.system ) return { message : 'That segment is not one this connection publishes.', skipped : true };
4984
+
4985
+ const name = tagName( segment.title );
4986
+ const existing = segmentRowFor({ connection, segment });
4987
+
4988
+ let id = null;
4989
+
4990
+ // BY ID FIRST, because that is the only path that can rename rather
4991
+ // than orphan. A 404 means the merchant deleted the tag themselves —
4992
+ // fall through and rebuild rather than failing a step they caused.
4993
+ if( existing?.id ){
4994
+
4995
+ try {
4996
+
4997
+ const found = await api( '/lists/' + audience + '/segments/' + existing.id, { dc : settings?.dc, fetcher, token });
4998
+
4999
+ id = found?.id ?? existing.id;
5000
+
5001
+ if( found?.name !== name ){
5002
+
5003
+ await api( '/lists/' + audience + '/segments/' + existing.id, {
5004
+ dc : settings?.dc,
5005
+ fetcher,
5006
+ method : 'PATCH',
5007
+ payload : { name },
5008
+ token
5009
+ });
5010
+
5011
+ }
5012
+
5013
+ } catch ( error ){
5014
+
5015
+ if( error.status !== 404 ) throw error;
5016
+
5017
+ id = null;
5018
+
5019
+ }
5020
+
5021
+ }
5022
+
5023
+ // BY NAME BEFORE CREATING, which NARROWS the window rather than
5024
+ // closing it. Two registers can overlap — a contact joining seconds
5025
+ // after the segment was made does it, and a drift re-queue carries a
5026
+ // deliberately unique job id so it can run alongside a fresh dispatch
5027
+ // — and creating without looking leaves a second tag nothing points
5028
+ // at. Two concurrent FIRST registers can still both search, both miss
5029
+ // and both create; only one row survives, so the loser is an orphaned
5030
+ // tag rather than a wrong one. Mailchimp offers no unique-name
5031
+ // constraint to close it properly.
5032
+ //
5033
+ // tag-search matches on PREFIX, not on the exact name: "The search
5034
+ // query will be compared to each tag as a prefix, so all tags that
5035
+ // have a name starting with this field will be returned"
5036
+ // (api.mailchimp.com/schema/3.0/Swagger.json, fetched 2026-09-11). So
5037
+ // "Drawbridge: VIP" answers for "Drawbridge: VIPs" too, and attaching
5038
+ // the row to the first result would point this segment at a different
5039
+ // merchant's tag. The exact name is filtered here.
5040
+ //
5041
+ // NO `count`, because the endpoint takes none: its only parameters are
5042
+ // the list id and `name`
5043
+ // (api.mailchimp.com/schema/3.0/Paths/Lists/TagSearch.json, fetched
5044
+ // 2026-09-11), unlike the audiences hook above where the default of ten
5045
+ // does bite. The response carries `total_items`, which is the one hint
5046
+ // that a page was cut — so if a merchant ever keeps more tags under one
5047
+ // Drawbridge prefix than a page holds, that field is where it shows.
5048
+ if( ! id ){
5049
+
5050
+ const search = await api( '/lists/' + audience + '/tag-search?name=' + encodeURIComponent( name ), { dc : settings?.dc, fetcher, token });
5051
+
5052
+ id = ( search?.tags || [] ).find( ( tag ) => tag?.name === name )?.id ?? null;
5053
+
5054
+ }
5055
+
5056
+ if( ! id ){
5057
+
5058
+ const created = await api( '/lists/' + audience + '/segments', {
5059
+ dc : settings?.dc,
5060
+ fetcher,
5061
+ method : 'POST',
5062
+ // STATIC WITH NO MEMBERS. The member sync attaches people by
5063
+ // name; this call only has to make the object exist. Mailchimp's
5064
+ // own wording for the empty array: "Passing an empty array will
5065
+ // create a static segment without any subscribers."
5066
+ payload : { name, static_segment : [] },
5067
+ token
5068
+ });
5069
+
5070
+ id = created?.id;
5071
+
5072
+ }
5073
+
5074
+ if( ! id ) return { message : 'Mailchimp returned no tag id.', skipped : true };
5075
+
5076
+ // THE AUDIENCE'S WEB ID, which is what the merchant's admin url is
5077
+ // keyed on — the api id in `settings.audience` does not address a page.
5078
+ // Read here rather than at page load, where the data centre is not
5079
+ // decrypted and a vendor round-trip would be on the critical path.
5080
+ const audienceDetail = await api( '/lists/' + audience + '?fields=web_id', { dc : settings?.dc, fetcher, token });
5081
+
5082
+ return {
5083
+ // A RENAME THAT ARRIVED WHILE THIS RAN was dropped by the
5084
+ // coalescing job id, so the last thing this does is look again.
5085
+ enqueues : await driftEnqueues({ applied : segment.title, read, segment, workflow }),
5086
+ events : [ {
5087
+ event : 'organization.segments',
5088
+ payload : { id : segment.id },
5089
+ room : 'organization.' + connection?.organization
5090
+ } ],
5091
+ message : 'Mailchimp is carrying this segment as the tag "' + name + '".',
5092
+ writes : segmentRowWrites({
5093
+ connection,
5094
+ data : { ...connection, settings },
5095
+ manifest,
5096
+ row : { id, type : 'tag', webId : audienceDetail?.web_id },
5097
+ segment
5098
+ })
5099
+ };
5100
+
5101
+ },
5102
+
5103
+ // THE TAG GOES WITH THE SEGMENT. Leaving it behind is the orphan this
5104
+ // whole pair exists to stop — every member would keep a label for a
5105
+ // segment that no longer exists.
5106
+ remove : async ( { connection, context, settings, token }, { fetcher } = {} ) => {
5107
+
5108
+ // NO RE-READ HERE. The segment is already deleted — the pre-image is
5109
+ // the only copy there is, and it carries the rows naming what to
5110
+ // remove.
5111
+ const segment = context?.segment;
5112
+ const existing = segmentRowFor({ connection, segment });
5113
+
5114
+ if( ! existing?.id ) return { message : 'Mailchimp was never carrying this segment.', skipped : true };
5115
+
5116
+ try {
5117
+
5118
+ await api( '/lists/' + settings?.audience + '/segments/' + existing.id, {
5119
+ dc : settings?.dc,
5120
+ fetcher,
5121
+ method : 'DELETE',
5122
+ token
5123
+ });
5124
+
5125
+ } catch ( error ){
5126
+
5127
+ // ALREADY GONE IS DONE. The merchant may have deleted it, and
5128
+ // retrying a 404 twice more achieves nothing.
5129
+ //
5130
+ // Mailchimp writes down no 404 for this call — Delete segment
5131
+ // declares a 204 and a generic problem detail and nothing else
5132
+ // (api.mailchimp.com/schema/3.0/Paths/Lists/Segments/Instance.json,
5133
+ // fetched 2026-09-11) — so this follows from their general error
5134
+ // table rather than from anything documented about deleting a
5135
+ // segment. It is still the right handling: a segment that is not
5136
+ // there is the outcome we wanted.
5137
+ if( error.status !== 404 ) throw error;
5138
+
5139
+ }
5140
+
5141
+ return {
5142
+ message : 'Mailchimp is no longer carrying this segment.',
5143
+ writes : segmentRowRemoveWrites({ connection, segment })
5144
+ };
5145
+
5146
+ },
5147
+
5148
+ // Drawbridge-side membership belongs to the private manifest.
5149
+ sync : false
5150
+
5151
+ },
5152
+ // Declined for the same reason as `email` above: Drawbridge sends its own
5153
+ // notification SMS, and a vendor answering this would be a second sender.
4267
5154
  sms : false,
4268
5155
  inbound : false,
4269
5156
  lifecycle : false,
@@ -4343,6 +5230,16 @@ var mailchimp = {
4343
5230
  'MAILCHIMP_OAUTH_CLIENT_ID',
4344
5231
  'MAILCHIMP_OAUTH_CLIENT_SECRET'
4345
5232
  ],
5233
+ // THE CHECKLIST'S RECEIPT — see drawbridge-docs reference/connection-hooks.md.
5234
+ review : {
5235
+ api : 'https://mailchimp.com/developer/marketing/guides/access-user-data-oauth-2/',
5236
+ dashboard : 'https://mailchimp.com/help/manage-tags/',
5237
+ // NO SCOPES EXIST. Mailchimp's OAuth guide describes none, and a token is
5238
+ // account-wide — so there is nothing to request and nothing to re-consent.
5239
+ scopes : false,
5240
+ content : '2026-09-11',
5241
+ verified : null
5242
+ },
4346
5243
  slug : 'mailchimp',
4347
5244
  // A grant with no audience chosen is authenticated and useless — the sync has
4348
5245
  // nowhere to put anyone — so the card must say Pending rather than Active over
@@ -4397,6 +5294,34 @@ var mailchimp = {
4397
5294
 
4398
5295
  })
4399
5296
 
5297
+ },
5298
+
5299
+ segment : {
5300
+
5301
+ // SYSTEM, so the builder never offers it and a merchant POST refuses it:
5302
+ // these fire from the segment's own lifecycle, not from a workflow
5303
+ // somebody assembled.
5304
+ //
5305
+ // The trigger is declared HERE rather than hard-coded in drawbridge-sync,
5306
+ // which is what lets a vendor arrive with its own without a queue edit.
5307
+ register : () => ({
5308
+ description : 'Keeps a matching tag in your Mailchimp audience for each Drawbridge segment, and renames it when the segment is renamed.',
5309
+ hook : 'segment.register',
5310
+ key : 'Mailchimp Segment Register',
5311
+ queue : 'connection',
5312
+ system : true,
5313
+ trigger : { event : 'segment.register', type : 'event' }
5314
+ }),
5315
+
5316
+ remove : () => ({
5317
+ description : 'Deletes the Mailchimp tag for a Drawbridge segment when the segment is deleted.',
5318
+ hook : 'segment.remove',
5319
+ key : 'Mailchimp Segment Remove',
5320
+ queue : 'connection',
5321
+ system : true,
5322
+ trigger : { event : 'segment.remove', type : 'event' }
5323
+ })
5324
+
4400
5325
  }
4401
5326
 
4402
5327
  },
@@ -4414,7 +5339,23 @@ var mailchimp = {
4414
5339
  }
4415
5340
  ]
4416
5341
  ),
4417
- title : 'Mailchimp'
5342
+ title : 'Mailchimp',
5343
+ // THE MERCHANT'S OWN ADMIN. Mailchimp's list schema states the shape outright:
5344
+ // the web_id field is "The ID used in the Mailchimp web application. View this
5345
+ // list in your Mailchimp account at
5346
+ // https://{dc}.admin.mailchimp.com/lists/members/?id={web_id}"
5347
+ // (api.mailchimp.com/schema/3.0/Definitions/Lists/Response.json, fetched
5348
+ // 2026-09-11).
5349
+ //
5350
+ // It lands on the audience's contacts, where the Drawbridge tag is one filter
5351
+ // away. Mailchimp documents no url that pre-selects a tag, so this stops one
5352
+ // click short rather than guessing at one that could break silently.
5353
+ urls : {
5354
+ segment : ( row, data ) => ( data?.settings?.dc && row?.webId
5355
+ ? 'https://' + data.settings.dc + '.admin.mailchimp.com/lists/members/?id=' + row.webId
5356
+ : null
5357
+ )
5358
+ }
4418
5359
  };
4419
5360
 
4420
5361
  // Shopify, exported from the brand kit and left as authored — the fills are the
@@ -4488,6 +5429,61 @@ const attributeLineItems = ( lineItems = [] ) => lineItems.reduce(
4488
5429
  // types them into a checkout.
4489
5430
  const generateDiscountCode = customAlphabet( '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ', 8 );
4490
5431
 
5432
+ // WHY A DISCOUNT CANNOT BACK AN ISSUED CODE, as a sentence for the merchant or
5433
+ // null when nothing is wrong. Every rule here is about the code we are about to
5434
+ // mint being redeemable by an ENTRANT we picked — which is a narrower question
5435
+ // than whether the discount is valid in general, and the reason a discount that
5436
+ // looks fine in Shopify admin can still be the wrong one to choose.
5437
+ //
5438
+ // Field semantics cited in drawbridge-shopify's discountsQuery.
5439
+ const blockedReason = ( discount ) => {
5440
+
5441
+ if( discount?.status === 'EXPIRED' ) return 'This discount has expired.';
5442
+
5443
+ // The union's "anyone" member. Everything else — named customers, a saved
5444
+ // segment, a market — restricts who may redeem, and a code we mint for an
5445
+ // entrant who is not on that list is a code that fails at checkout. Creating
5446
+ // the customer would not fix it: joining a discount's eligibility list is a
5447
+ // separate write we do not make.
5448
+ const buyers = discount?.context?.__typename;
5449
+
5450
+ if( buyers && buyers !== 'DiscountBuyerSelectionAll' ){
5451
+
5452
+ return 'This discount is limited to specific buyers in Shopify, so a code issued to an entrant won\'t work. Set it to all customers to use it here.';
5453
+
5454
+ }
5455
+ // Documented as possibly lagging the true count, so this can only ever
5456
+ // under-report — count >= limit means genuinely exhausted.
5457
+ if( typeof discount?.usageLimit === 'number' && discount.usageLimit > 0
5458
+ && ( discount?.asyncUsageCount || 0 ) >= discount.usageLimit ){
5459
+
5460
+ return 'This discount has reached its total usage limit.';
5461
+
5462
+ }
5463
+ return null;
5464
+
5465
+ };
5466
+
5467
+ // Usable, but about to behave in a way the merchant did not ask for. Separate
5468
+ // from blockedReason because the answer here is "go ahead, knowing this" — a
5469
+ // scheduled discount is the normal way to set up a campaign in advance, and
5470
+ // refusing it would be wrong.
5471
+ const discountWarning = ( discount ) => {
5472
+
5473
+ if( discount?.status === 'SCHEDULED' ){
5474
+
5475
+ return 'This discount hasn\'t started yet, so codes issued before it does won\'t work until then.';
5476
+
5477
+ }
5478
+ // One redemption per person, which is usually intended — but it is the
5479
+ // difference between a code that can be forwarded and one that cannot, and
5480
+ // merchants do not expect a per-entrant code to also be per-person capped.
5481
+ if( discount?.appliesOncePerCustomer ) return 'Each customer can use this discount only once.';
5482
+
5483
+ return null;
5484
+
5485
+ };
5486
+
4491
5487
  // THE USAGE METER'S EVENT HANDLE — the string that decides whether an order's
4492
5488
  // billing event bills or is silently ingested as a plain custom event. It must
4493
5489
  // equal, case-sensitively, the meter HANDLE configured on the app's plan in
@@ -4588,7 +5584,7 @@ var shopify = {
4588
5584
  description : [
4589
5585
  'Installing the Drawbridge app from the Shopify App Store links your store to a single Drawbridge organization and makes your product catalog available inside Drawbridge, so you can feature products in your campaigns and advertisements.',
4590
5586
  'Drawbridge attributes orders that originate from your campaigns — matched through cart parameters and lead-mapped discount codes — so you can see the revenue each campaign drives.',
4591
- 'On connect, Drawbridge registers webhooks for product and order updates to keep your catalog and revenue in sync. Disconnecting removes those webhooks and unlinks the resources.'
5587
+ 'Drawbridge reads your products, records orders placed through your campaigns, and can issue discount codes. Order and product updates reach Drawbridge through the app\'s own webhooks, which Shopify applies when the app is installed.'
4592
5588
  ],
4593
5589
  errors : {
4594
5590
  connect : {
@@ -4602,7 +5598,7 @@ var shopify = {
4602
5598
  'Open the Drawbridge listing on the Shopify App Store.',
4603
5599
  'Install the app on the store you want to connect. It opens in Shopify admin and stays there.',
4604
5600
  'Approve the Drawbridge plan when prompted — during install, or from the connection page here. The connection shows Pending until you do, then Active.',
4605
- 'Come back here — the connections list updates on its own once the install lands.'
5601
+ 'Come back here — the connections list updates on its own once the install finishes.'
4606
5602
  ],
4607
5603
  // Names where the link GOES rather than what it does: installing happens on
4608
5604
  // the App Store listing, and the dashboard must never imply a store can be
@@ -5125,14 +6121,27 @@ var shopify = {
5125
6121
  ? { domain : connection.source.domain, id : connection.source.id }
5126
6122
  : undefined;
5127
6123
 
6124
+ // EVERY PURCHASE GETS AN ORDER DOCUMENT, whichever way it reached us.
6125
+ //
6126
+ // A redemption used to write only a redemption row, which meant the
6127
+ // money existed on the Redemptions page and nowhere else: contact
6128
+ // totals are summed from the ORDER collection, so a real purchase by a
6129
+ // known entrant contributed nothing to their revenue and was invisible
6130
+ // to every revenue segment. `type` keeps the two kinds apart for the
6131
+ // figures that must stay causal (the fee, the Revenue page's
6132
+ // conversion column) without splitting the source of truth in two.
6133
+ const createsOrder = ! backfill && ( isConversion || Boolean( discount ) );
6134
+
5128
6135
  // MINTED HERE, because the redemption names its order and the usage
5129
6136
  // job names both — a description cannot read a write's result, so the
5130
- // id exists before either does.
5131
- const orderDocId = existingOrder?.id || ( isConversion && ! backfill ? mintId() : null );
6137
+ // id exists before either does. Minted for the redemption too, so the
6138
+ // order can name it back.
6139
+ const orderDocId = existingOrder?.id || ( createsOrder ? mintId() : null );
6140
+ const redemptionDocId = discount ? mintId() : null;
5132
6141
 
5133
6142
  const writes = [];
5134
6143
 
5135
- if( isConversion && ! backfill ){
6144
+ if( createsOrder ){
5136
6145
 
5137
6146
  writes.push({
5138
6147
  collection : 'order',
@@ -5154,8 +6163,12 @@ var shopify = {
5154
6163
  provider : { id : String( orderId ), slug : 'shopify' },
5155
6164
  purchasedAt,
5156
6165
  rate,
6166
+ // Null on a conversion that matched no code of ours; the
6167
+ // backfill branch below sets it when one arrives later.
6168
+ redemption : redemptionDocId,
5157
6169
  source,
5158
- status : 'completed'
6170
+ status : 'completed',
6171
+ type : isConversion ? 'conversion' : 'redemption'
5159
6172
  },
5160
6173
  operation : 'create'
5161
6174
  });
@@ -5164,7 +6177,14 @@ var shopify = {
5164
6177
 
5165
6178
  writes.push({
5166
6179
  collection : 'usage',
5167
- data : { $inc : { 'totals.revenue' : gross } },
6180
+ // TWO METERS, NOT ONE SUMMED. `revenue` has always meant
6181
+ // conversion revenue and is the figure the fee is charged
6182
+ // against, so redemption money gets its own key rather than
6183
+ // changing what an existing number means.
6184
+ data : { $inc : isConversion
6185
+ ? { 'totals.revenue' : gross }
6186
+ : { 'totals.redemptionRevenue' : gross }
6187
+ },
5168
6188
  operation : 'update',
5169
6189
  query : { id : org.usage }
5170
6190
  });
@@ -5175,7 +6195,15 @@ var shopify = {
5175
6195
 
5176
6196
  writes.push({
5177
6197
  collection : 'lead',
5178
- data : { $inc : { 'totals.orders' : 1 } },
6198
+ // Same grouped shape the contact carries, so a lead and the
6199
+ // contact built from it cannot be read two different ways.
6200
+ data : { $inc : {
6201
+ 'totals.orders.total' : 1,
6202
+ ...( isConversion
6203
+ ? { 'totals.orders.conversions' : 1 }
6204
+ : { 'totals.orders.redemptions' : 1 }
6205
+ )
6206
+ } },
5179
6207
  operation : 'update',
5180
6208
  options : { bypassDocumentValidation : true },
5181
6209
  query : { id : leadId }
@@ -5198,6 +6226,7 @@ var shopify = {
5198
6226
  customer,
5199
6227
  discount,
5200
6228
  gross,
6229
+ id : redemptionDocId,
5201
6230
  lead : leadId,
5202
6231
  order : orderDocId,
5203
6232
  organization : campaignOrganization,
@@ -5233,6 +6262,23 @@ var shopify = {
5233
6262
 
5234
6263
  }
5235
6264
 
6265
+ // THE BACKFILL LEG: a conversion recorded earlier, matched to one of
6266
+ // our codes now. The order already exists, so nothing above created
6267
+ // it and nothing has told it which redemption it belongs to — this
6268
+ // is the only write that closes that link. Skipped when the order
6269
+ // was created in this same run, because it was minted carrying the
6270
+ // id already.
6271
+ if( backfill && orderDocId && redemptionDocId ){
6272
+
6273
+ writes.push({
6274
+ collection : 'order',
6275
+ data : { $set : { redemption : redemptionDocId } },
6276
+ operation : 'update',
6277
+ query : { id : orderDocId }
6278
+ });
6279
+
6280
+ }
6281
+
5236
6282
  }
5237
6283
 
5238
6284
  // SHOPIFY-BILLED ORGS ARE CHARGED THROUGH SHOPIFY, keyed on the order
@@ -6020,10 +7066,24 @@ var shopify = {
6020
7066
  // The GLOBAL id is what Shopify returns and the bare id is what a
6021
7067
  // picker stores, which is why the tail is taken here rather than by
6022
7068
  // each caller that happened to remember.
6023
- items : ( discounts?.edges || [] ).map( ( edge ) => ({
6024
- id : String( edge?.node?.id || '' ).split( '/' ).pop(),
6025
- title : edge?.node?.codeDiscount?.title
6026
- }) ),
7069
+ items : ( discounts?.edges || [] ).map( ( edge ) => {
7070
+
7071
+ const node = edge?.node?.codeDiscount || {};
7072
+
7073
+ return {
7074
+ // Null when the discount can be used, a sentence when it cannot.
7075
+ // The picker greys the row and shows this instead of hiding it:
7076
+ // a discount the merchant can see in Shopify admin, missing here
7077
+ // with no explanation, reads as a bug in us.
7078
+ blocked : blockedReason( node ),
7079
+ id : String( edge?.node?.id || '' ).split( '/' ).pop(),
7080
+ // Usable, but not in the way the merchant probably expects.
7081
+ // Shown beside the row without stopping them.
7082
+ warning : discountWarning( node ),
7083
+ title : node.title
7084
+ };
7085
+
7086
+ }),
6027
7087
  pageInfo : {
6028
7088
  endCursor : discounts?.pageInfo?.endCursor || null,
6029
7089
  hasNextPage : Boolean( discounts?.pageInfo?.hasNextPage )
@@ -6039,24 +7099,6 @@ var shopify = {
6039
7099
  },
6040
7100
  icon,
6041
7101
  inbound,
6042
- // THE DEEP LINK into this store's Drawbridge app inside Shopify admin.
6043
- //
6044
- // Here rather than in drawbridge-api, which had `slug === 'shopify' && {...}`
6045
- // in the shared resolver — a hardcoded vendor branch in code every vendor runs
6046
- // through, which is the arrangement these manifests exist to remove.
6047
- //
6048
- // Undefined until a shop is linked, so the Manage button only appears on a
6049
- // connected connection. The app handle is NAMED by `requires` and read from
6050
- // the env the resolver passes, never from process.env here.
6051
- manage : ( data, env ) => {
6052
-
6053
- const shop = data?.shop || data?.settings?.domain;
6054
-
6055
- return shop
6056
- ? 'https://admin.shopify.com/store/' + String( shop ).replace( '.myshopify.com', '' ) + '/apps/' + env?.SHOPIFY_APP_HANDLE
6057
- : undefined;
6058
-
6059
- },
6060
7102
  // DRAWBRIDGE'S OWN CREDENTIALS for this vendor, as opposed to a merchant's —
6061
7103
  // what an admin types on the provider screen. The four names below are exactly
6062
7104
  // what `requires` gates on, which is the point of declaring them together: a
@@ -6102,6 +7144,14 @@ var shopify = {
6102
7144
  'SHOPIFY_APP_LISTING_URL',
6103
7145
  'SHOPIFY_APP_HANDLE'
6104
7146
  ],
7147
+ // THE CHECKLIST'S RECEIPT — see drawbridge-docs reference/connection-hooks.md.
7148
+ review : {
7149
+ api : 'https://shopify.dev/docs/api/admin-graphql',
7150
+ dashboard : 'https://help.shopify.com/en/manual/apps',
7151
+ scopes : 'https://shopify.dev/docs/api/usage/access-scopes',
7152
+ content : '2026-09-11',
7153
+ verified : null
7154
+ },
6105
7155
  slug : 'shopify',
6106
7156
  // The install is the whole configuration — Shopify hands back the shop and
6107
7157
  // there is nothing further to choose. `shop` absent means the install did not
@@ -6194,8 +7244,11 @@ var shopify = {
6194
7244
  },
6195
7245
 
6196
7246
  // SYSTEM STEPS: dispatched by drawbridge-sync itself rather than offered
6197
- // in the builder, so they carry no trigger and no usage. Declared because
6198
- // the routing table and the system-workflow descriptions both read here.
7247
+ // in the builder, so they carry no usage. These two are fired by a webhook
7248
+ // arriving rather than by a workflow trigger, so they name none either —
7249
+ // and naming none is what stops a workflow being provisioned for them.
7250
+ // Declared because the routing table and the system-workflow descriptions
7251
+ // both read here.
6199
7252
  order : {
6200
7253
  record : () => ({
6201
7254
  description : 'Records an order and billing charge when a purchase is made via a Drawbridge campaign link.',
@@ -6231,7 +7284,8 @@ var shopify = {
6231
7284
  hook : 'lifecycle.health',
6232
7285
  key : 'Shopify Connection Health',
6233
7286
  queue : 'connection',
6234
- system : true
7287
+ system : true,
7288
+ trigger : { event : 'day', type : 'schedule' }
6235
7289
  })
6236
7290
  },
6237
7291
 
@@ -6298,7 +7352,34 @@ var shopify = {
6298
7352
  : []
6299
7353
  )
6300
7354
  ],
6301
- title : 'Shopify'
7355
+ title : 'Shopify',
7356
+ // THE VENDOR'S OWN ADMIN, one function per thing worth linking to. It lives
7357
+ // here rather than at the top level so a second link (a product, an order)
7358
+ // is a key in this object instead of a new manifest key nobody agreed on.
7359
+ //
7360
+ // AND HERE RATHER THAN IN drawbridge-api, which had `slug === 'shopify' &&
7361
+ // {...}` in the shared resolver — a hardcoded vendor branch in code every
7362
+ // vendor runs through, which is the arrangement these manifests exist to
7363
+ // remove.
7364
+ //
7365
+ // Never projected: the api composes connect.manage from it, and
7366
+ // resolveConnection drops the object, because a url built from settings is
7367
+ // built where the settings are already decrypted.
7368
+ urls : {
7369
+ // Undefined until a shop is linked, so the Manage button only appears on a
7370
+ // connected connection. The app handle is NAMED by `requires` and read from
7371
+ // the env its caller passes — the api's resolve() hands it the stored
7372
+ // credentials, never process.env.
7373
+ manage : ( data, env ) => {
7374
+
7375
+ const shop = data?.shop || data?.settings?.domain;
7376
+
7377
+ return shop
7378
+ ? 'https://admin.shopify.com/store/' + String( shop ).replace( '.myshopify.com', '' ) + '/apps/' + env?.SHOPIFY_APP_HANDLE
7379
+ : undefined;
7380
+
7381
+ }
7382
+ }
6302
7383
  };
6303
7384
 
6304
7385
  // Webhooks — the only connection with no third party behind it. Connecting
@@ -6355,7 +7436,7 @@ var webhook = {
6355
7436
  content : {
6356
7437
  confirm : 'Disconnecting stops Drawbridge from sending signed webhook payloads to your endpoint.',
6357
7438
  description : [
6358
- 'Drawbridge can POST event payloads to your endpoint as activity happens in your account, so your own systems can react in real time.',
7439
+ 'Drawbridge can POST event payloads to your endpoint as activity happens in your account, so your own systems can react to it.',
6359
7440
  'Generate a signing secret and Drawbridge signs every request with it. Your endpoint recomputes the signature to confirm each payload genuinely came from Drawbridge before acting on it.'
6360
7441
  ],
6361
7442
  excerpt : 'Sign outgoing webhook payloads with an HMAC secret to verify authenticity.',
@@ -6481,6 +7562,9 @@ var webhook = {
6481
7562
  // Gated on the encryption secret: without it the signing secret could not be
6482
7563
  // stored safely, so the connection must not be offered at all.
6483
7564
  requires : [ 'ENCRYPT_CONNECTION_SECRET' ],
7565
+ // NO THIRD PARTY AT ALL. There is no vendor reference to read, no dashboard
7566
+ // to link to and no scope to request: connecting mints a secret.
7567
+ review : false,
6484
7568
  // Outbound only. inbound.* is false because the direction is the point: we
6485
7569
  // sign and POST to the merchant's endpoint, they never call us. Every other
6486
7570
  // false follows from there being no third party to authenticate against —
@@ -7475,7 +8559,7 @@ const redactSettings = ({ slug, settings }) => {
7475
8559
  const publicConnectionKeys = Object.freeze([
7476
8560
  'actions',
7477
8561
  // API-COMPOSED, not manifest-declared: the api's resolve() builds it from
7478
- // auth.type, content.redirect and the manifest's manage() — the client reads
8562
+ // auth.type, content.redirect and the manifest's urls.manage() — the client reads
7479
8563
  // connect.type to choose entered-vs-installed, connect.redirect for the App
7480
8564
  // Store link, connect.manage for the admin deep link. It was dropped from
7481
8565
  // this list when the manifests stopped declaring it, which stripped the
@@ -7567,7 +8651,15 @@ const resolveConnection = ( item, data, env = {} ) => {
7567
8651
  // answer baked a Pending badge into Klaviyo, Mailchimp and Attentive
7568
8652
  // cards nobody had connected. Status belongs to the caller's own
7569
8653
  // read-time pass (api's applyStatus), never to projection.
7570
- .filter( ( [ key ] ) => ! [ 'auth', 'enabled', 'fields', 'hooks', 'inbound', 'provider', 'requires', 'status', 'steps', 'supports' ].includes( key ) )
8654
+ // `urls` is dropped for the same reason `provider` is: it holds raw
8655
+ // functions, and a url built from stored settings must be built where
8656
+ // those settings are already decrypted — the api calls urls.manage()
8657
+ // itself and composes the result into connect.
8658
+ //
8659
+ // `review` is dropped because it is engineering metadata — which docs
8660
+ // were read, on what day — with no client that reads it. Left in, it
8661
+ // would ride on every connection response a merchant's browser loads.
8662
+ .filter( ( [ key ] ) => ! [ 'auth', 'enabled', 'fields', 'hooks', 'inbound', 'provider', 'requires', 'review', 'status', 'steps', 'supports', 'urls' ].includes( key ) )
7571
8663
  .map( ( [ key, value ] ) => [
7572
8664
  key,
7573
8665
  ( typeof value === 'function' ? value( data, env ) : value )