@drawbridge/drawbridge-utils 0.0.168 → 0.0.170

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
@@ -3494,6 +3867,13 @@ var klaviyo = {
3494
3867
  // fetched 2026-09-09).
3495
3868
  //
3496
3869
  // IT MERGES, and that is why the blocks below are conditional rather
3870
+ // GROUPED OR FLAT, read as one number. contact.totals.orders and .gross
3871
+ // are { total, conversions, redemptions } now, but a contact the
3872
+ // backfill has not reached still holds the old flat number — and a
3873
+ // naked `totals.orders || 0` would hand Klaviyo the whole object as a
3874
+ // profile property. Klaviyo gets the total either way.
3875
+ const count = ( value ) => typeof value === 'number' ? value : ( value?.total || 0 );
3876
+
3497
3877
  // than defaulted. "Not including a field in your request will leave it
3498
3878
  // unchanged" — so omitting the totals on a lead.insert preserves
3499
3879
  // whatever the last segment run published, where sending zeros would
@@ -3511,13 +3891,13 @@ var klaviyo = {
3511
3891
  drawbridge_campaigns : ( person.campaigns || [] ).length,
3512
3892
  drawbridge_draws : totals.draws || 0,
3513
3893
  drawbridge_entries : totals.entries || 0,
3514
- drawbridge_orders : totals.orders || 0,
3894
+ drawbridge_orders : count( totals.orders ),
3515
3895
  // Campaign-attributed, NOT lifetime. A merchant running
3516
3896
  // Shopify already has lifetime revenue in Klaviyo through
3517
3897
  // Klaviyo's own integration; what only we can say is how
3518
3898
  // much a campaign drove. Named so the two cannot be
3519
3899
  // mistaken for one another in a segment builder.
3520
- drawbridge_revenue : totals.gross || 0
3900
+ drawbridge_revenue : count( totals.gross )
3521
3901
  }),
3522
3902
  // THE DRAWBRIDGE SEGMENTS THEY ARE IN, as a list property the
3523
3903
  // merchant builds Klaviyo segments on top of. Klaviyo owns no
@@ -3536,7 +3916,14 @@ var klaviyo = {
3536
3916
  // `segments` is null when the run carried no contact document,
3537
3917
  // meaning nobody looked — different from [], which means they
3538
3918
  // are in none. Null omits the key and merge leaves it alone.
3539
- ...( segments && { drawbridge_segments : segments.map( ( entry ) => entry.title ).filter( Boolean ) })
3919
+ ...( segments && { drawbridge_segments : segments.map( ( entry ) => entry.title ).filter( Boolean ) }),
3920
+ // THE IDS, which is what a Drawbridge-made segment's definition
3921
+ // filters on. Ids rather than titles, so renaming a segment is a
3922
+ // name change at Klaviyo and not a resync of every profile.
3923
+ //
3924
+ // The titles stay beside them: merchants have been building
3925
+ // their own segments on that array since it shipped.
3926
+ ...( segments && { drawbridge_segment_ids : segments.map( ( entry ) => entry.id ).filter( Boolean ) })
3540
3927
  }
3541
3928
  },
3542
3929
  type : 'profile'
@@ -3599,17 +3986,218 @@ var klaviyo = {
3599
3986
 
3600
3987
  },
3601
3988
 
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.
3989
+ // Drawbridge sends its own notification email. A vendor answering this
3990
+ // would be a second sender, which is the arrangement the platform sender
3991
+ // replaced. Declined as one line rather than one per verb, because the whole
3992
+ // domain is one decision — still explicit, since absence would not say
3993
+ // whether anybody considered it.
3610
3994
  email : false,
3611
- segment : false,
3995
+
3996
+ // SEGMENTS ARE SHARED, not owned by one side. Drawbridge holds the
3997
+ // membership — see the private `drawbridge` manifest, and `sync : false`
3998
+ // below — while register and remove keep a Klaviyo segment standing for
3999
+ // each Drawbridge segment, so the merchant can target one in their own
4000
+ // flows.
4001
+ segment : {
4002
+
4003
+ // THE KLAVIYO SEGMENT THIS DRAWBRIDGE SEGMENT BECOMES.
4004
+ //
4005
+ // Klaviyo owns no writable membership — its segments are computed from
4006
+ // rules — so the segment we create is DEFINED BY the profile property
4007
+ // contacts.sync writes. The definition filters on the Drawbridge
4008
+ // segment's ID, never its title, which is what makes a rename one PATCH
4009
+ // instead of a resync of every profile in it.
4010
+ register : async ( { connection, context, manifest, settings, token, workflow }, { fetcher, read } = {} ) => {
4011
+
4012
+ // AS IT IS NOW — see the same note on Mailchimp's. One job id serves
4013
+ // four dispatch sites, so the trigger data is whichever one won.
4014
+ const segment = await currentSegment({ read, segment : context?.segment });
4015
+
4016
+ if( ! segment?.id || segment.system ) return { message : 'That segment is not one this connection publishes.', skipped : true };
4017
+
4018
+ if( ! canManageSegments( settings ) ){
4019
+
4020
+ return {
4021
+ message : 'Reconnect Klaviyo to let Drawbridge manage segments — this connection was made before that permission was asked for.',
4022
+ skipped : true
4023
+ };
4024
+
4025
+ }
4026
+
4027
+ const name = segmentName( segment.title );
4028
+ const existing = segmentRowFor({ connection, segment });
4029
+
4030
+ let id = null;
4031
+
4032
+ if( existing?.id ){
4033
+
4034
+ try {
4035
+
4036
+ const found = await api$1( '/segments/' + existing.id, { fetcher, token });
4037
+
4038
+ id = found?.data?.id ?? existing.id;
4039
+
4040
+ if( found?.data?.attributes?.name !== name ){
4041
+
4042
+ // NAME ONLY. Update Segment takes `name` on its own —
4043
+ // nothing in its attributes is required — and the definition
4044
+ // keys on the segment id, which has not changed, so rewriting
4045
+ // it would rebuild the segment for nothing.
4046
+ await api$1( '/segments/' + existing.id, {
4047
+ fetcher,
4048
+ method : 'PATCH',
4049
+ payload : { data : { attributes : { name }, id : existing.id, type : 'segment' } },
4050
+ token
4051
+ });
4052
+
4053
+ }
4054
+
4055
+ } catch ( error ){
4056
+
4057
+ // A 404 means the merchant deleted the segment themselves — fall
4058
+ // through and rebuild rather than failing a step they caused.
4059
+ if( error.status !== 404 ) throw error;
4060
+
4061
+ id = null;
4062
+
4063
+ }
4064
+
4065
+ }
4066
+
4067
+ // BY NAME BEFORE CREATING, which NARROWS the window rather than
4068
+ // closing it. Klaviyo segment names are not unique, so two
4069
+ // overlapping registers — a contact joining seconds after the segment
4070
+ // was made, or a drift re-queue whose deliberately unique job id lets
4071
+ // it run alongside a fresh dispatch — would otherwise leave a second
4072
+ // segment nothing points at. Two concurrent FIRST registers can still
4073
+ // both search, both miss and both create; only one row survives, so
4074
+ // the loser is an orphaned segment rather than a wrong one.
4075
+ //
4076
+ // `equals` is one of the two operators Get Segments allows on `name`,
4077
+ // and a string argument is quoted: "we will accept either single
4078
+ // quoted or double-quoted strings"
4079
+ // (developers.klaviyo.com/en/docs/filtering_, fetched 2026-09-11).
4080
+ // The whole expression is URI-encoded, which the same page requires.
4081
+ //
4082
+ // THE TITLE IS ESCAPED, because it is free text a merchant types and a
4083
+ // double quote in it would otherwise close the literal early — a
4084
+ // malformed filter is a 400 the step retries three times before
4085
+ // failing with no row written. The same page gives the escape: "Single
4086
+ // or double-quoted characters within strings (quoted with like quote
4087
+ // characters) MUST be escaped with a single backslash (i.e. 'Tony\'s
4088
+ // ball')". Only the matching quote needs it, so a double-quoted
4089
+ // literal escapes double quotes and leaves apostrophes alone.
4090
+ if( ! id ){
4091
+
4092
+ const search = await api$1( '/segments?filter=' + encodeURIComponent( 'equals(name,"' + name.replace( /"/g, '\\"' ) + '")' ), { fetcher, token });
4093
+
4094
+ id = ( search?.data || [] ).find( ( entry ) => entry?.attributes?.name === name )?.id ?? null;
4095
+
4096
+ }
4097
+
4098
+ if( ! id ){
4099
+
4100
+ const created = await api$1( '/segments', {
4101
+ fetcher,
4102
+ method : 'POST',
4103
+ // THE DEFINITION IS THE MEMBERSHIP. Create Segment requires one
4104
+ // — `name` and `definition` are both required on its attributes
4105
+ // — and a custom profile property is addressed as
4106
+ // "properties['property name']", tested with a list filter whose
4107
+ // operator is `contains`
4108
+ // (raw.githubusercontent.com/klaviyo/openapi/main/openapi/stable.json,
4109
+ // revision 2026-07-15, fetched 2026-09-11).
4110
+ payload : {
4111
+ data : {
4112
+ attributes : {
4113
+ definition : {
4114
+ condition_groups : [ {
4115
+ conditions : [ {
4116
+ filter : { operator : 'contains', type : 'list', value : segment.id },
4117
+ property : 'properties[\'drawbridge_segment_ids\']',
4118
+ type : 'profile-property'
4119
+ } ]
4120
+ } ]
4121
+ },
4122
+ name
4123
+ },
4124
+ type : 'segment'
4125
+ }
4126
+ },
4127
+ token
4128
+ });
4129
+
4130
+ id = created?.data?.id;
4131
+
4132
+ }
4133
+
4134
+ if( ! id ) return { message : 'Klaviyo returned no segment id.', skipped : true };
4135
+
4136
+ return {
4137
+ // A RENAME THAT ARRIVED WHILE THIS RAN was dropped by the
4138
+ // coalescing job id, so the last thing this does is look again.
4139
+ enqueues : await driftEnqueues({ applied : segment.title, read, segment, workflow }),
4140
+ events : [ {
4141
+ event : 'organization.segments',
4142
+ payload : { id : segment.id },
4143
+ room : 'organization.' + connection?.organization
4144
+ } ],
4145
+ message : 'Klaviyo is carrying this segment as "' + name + '".',
4146
+ writes : segmentRowWrites({
4147
+ connection,
4148
+ data : { ...connection, settings },
4149
+ manifest,
4150
+ row : { id, type : 'segment' },
4151
+ segment
4152
+ })
4153
+ };
4154
+
4155
+ },
4156
+
4157
+ // NO RE-READ. The segment is already deleted; the pre-image is the only
4158
+ // copy, and it carries the row naming what to delete.
4159
+ remove : async ( { connection, context, settings, token }, { fetcher } = {} ) => {
4160
+
4161
+ const segment = context?.segment;
4162
+ const existing = segmentRowFor({ connection, segment });
4163
+
4164
+ if( ! existing?.id ) return { message : 'Klaviyo was never carrying this segment.', skipped : true };
4165
+
4166
+ if( ! canManageSegments( settings ) ){
4167
+
4168
+ return { message : 'Reconnect Klaviyo to let Drawbridge manage segments.', skipped : true };
4169
+
4170
+ }
4171
+
4172
+ try {
4173
+
4174
+ await api$1( '/segments/' + existing.id, { fetcher, method : 'DELETE', token });
4175
+
4176
+ } catch ( error ){
4177
+
4178
+ // ALREADY GONE IS DONE. The merchant may have deleted it, and
4179
+ // retrying a 404 twice more achieves nothing.
4180
+ if( error.status !== 404 ) throw error;
4181
+
4182
+ }
4183
+
4184
+ return {
4185
+ message : 'Klaviyo is no longer carrying this segment.',
4186
+ writes : segmentRowRemoveWrites({ connection, segment })
4187
+ };
4188
+
4189
+ },
4190
+
4191
+ // Drawbridge-side membership belongs to the private manifest.
4192
+ sync : false
4193
+
4194
+ },
4195
+ // Declined for the same reason as `email` above: Drawbridge sends its own
4196
+ // notification SMS, and a vendor answering this would be a second sender.
3612
4197
  sms : false,
4198
+
4199
+ // Klaviyo sends us nothing — no inbound message to receive, no signature
4200
+ // to verify.
3613
4201
  inbound : false,
3614
4202
 
3615
4203
  // Nothing to set up or tear down at the vendor: the grant is the whole
@@ -3785,6 +4373,18 @@ var klaviyo = {
3785
4373
  'KLAVIYO_OAUTH_CLIENT_ID',
3786
4374
  'KLAVIYO_OAUTH_CLIENT_SECRET'
3787
4375
  ],
4376
+ // THE CHECKLIST'S RECEIPT — see drawbridge-docs reference/connection-hooks.md.
4377
+ review : {
4378
+ api : 'https://developers.klaviyo.com/en/reference/api_overview',
4379
+ dashboard : 'https://help.klaviyo.com/hc/en-us/articles/115005078647',
4380
+ // THE SCOPE TABLE, not the OAuth walk-through. set_up_oauth carries one
4381
+ // example scope string and nothing to check a manifest against; this page
4382
+ // lists the scopes each API takes, segments:read and segments:write among
4383
+ // them (fetched 2026-09-11).
4384
+ scopes : 'https://developers.klaviyo.com/en/docs/authenticate_',
4385
+ content : '2026-09-11',
4386
+ verified : null
4387
+ },
3788
4388
  slug : 'klaviyo',
3789
4389
  // ONE OF THE FOUR STATES AND NOTHING ELSE — the reason sits in `tasks`, which
3790
4390
  // is already the merchant-facing copy channel and is already rendered.
@@ -3815,7 +4415,8 @@ var klaviyo = {
3815
4415
  hook : 'lifecycle.health',
3816
4416
  key : 'Klaviyo Connection Health',
3817
4417
  queue : 'connection',
3818
- system : true
4418
+ system : true,
4419
+ trigger : { event : 'day', type : 'schedule' }
3819
4420
  })
3820
4421
  }
3821
4422
 
@@ -3868,6 +4469,32 @@ var klaviyo = {
3868
4469
 
3869
4470
  })
3870
4471
 
4472
+ },
4473
+
4474
+ segment : {
4475
+
4476
+ // SYSTEM, so the builder never offers it and a merchant POST refuses it:
4477
+ // these fire from the segment's own lifecycle, not from a workflow
4478
+ // somebody assembled. The trigger is declared here rather than hard-coded
4479
+ // in drawbridge-sync.
4480
+ register : () => ({
4481
+ description : 'Keeps a matching Klaviyo segment for each Drawbridge segment, built on the segment ids Drawbridge writes onto your profiles.',
4482
+ hook : 'segment.register',
4483
+ key : 'Klaviyo Segment Register',
4484
+ queue : 'connection',
4485
+ system : true,
4486
+ trigger : { event : 'segment.register', type : 'event' }
4487
+ }),
4488
+
4489
+ remove : () => ({
4490
+ description : 'Deletes the Klaviyo segment for a Drawbridge segment when the segment is deleted.',
4491
+ hook : 'segment.remove',
4492
+ key : 'Klaviyo Segment Remove',
4493
+ queue : 'connection',
4494
+ system : true,
4495
+ trigger : { event : 'segment.remove', type : 'event' }
4496
+ })
4497
+
3871
4498
  }
3872
4499
 
3873
4500
  },
@@ -3876,17 +4503,43 @@ var klaviyo = {
3876
4503
  // is reconnecting — prompting "choose a list" there asks the merchant to
3877
4504
  // configure a grant that no longer exists. `pending` is exactly this task's
3878
4505
  // 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
- {
4506
+ tasks : ( data ) => {
4507
+
4508
+ if( ! [ 'active', 'pending' ].includes( data?.status ) ) return [];
4509
+
4510
+ // BOTH, WHEN BOTH APPLY. These are independent facts about one connection
4511
+ // — an old grant that cannot manage segments, and a list nobody picked —
4512
+ // and returning only the first means a merchant fixes it, comes back, and
4513
+ // discovers the second. The grant leads because reconnecting is the longer
4514
+ // errand.
4515
+ return [
4516
+ // A connection made before segments were requested is authenticated and
4517
+ // cannot manage them, and no error surfaces anywhere else — the register
4518
+ // runs skip rather than fail.
4519
+ ...( canManageSegments( data?.settings ) ? [] : [ {
4520
+ message : 'Drawbridge now keeps a Klaviyo segment in step with each of your Drawbridge segments. Reconnect Klaviyo to allow it.',
4521
+ title : 'Reconnect Klaviyo'
4522
+ } ] ),
4523
+ ...( data?.settings?.list ? [] : [ {
3883
4524
  message : 'Choose which Klaviyo list your contacts should sync into. Until you do, nothing is being synced.',
3884
4525
  title : 'Choose a list'
3885
- }
3886
- ]
3887
- ),
4526
+ } ] )
4527
+ ];
3888
4528
 
3889
- title : 'Klaviyo'
4529
+ },
4530
+
4531
+ title : 'Klaviyo',
4532
+
4533
+ // KLAVIYO PUBLISHES NO DASHBOARD URLS in its API reference. What is on record
4534
+ // is its own help centre on a list: "you can find a list's ID in the URL in
4535
+ // your browser when viewing this list"
4536
+ // (help.klaviyo.com/hc/en-us/articles/115005078647, fetched 2026-09-11), and a
4537
+ // segment's page is the sibling form of it. The path itself is NOT published
4538
+ // anywhere citable, so the dev walk-through confirms this against a real
4539
+ // account before promote.
4540
+ urls : {
4541
+ segment : ( row ) => ( row?.id ? 'https://www.klaviyo.com/segment/' + row.id : null )
4542
+ }
3890
4543
  };
3891
4544
 
3892
4545
  // Mailchimp, exported from the brand kit and left as authored — the fills are the
@@ -3967,6 +4620,28 @@ const subscriberHash = ( email ) => createHash( 'md5' )
3967
4620
  .update( String( email ).trim().toLowerCase() )
3968
4621
  .digest( 'hex' );
3969
4622
 
4623
+ // THE TAG'S NAME, and the one place it is spelled. The member write and the
4624
+ // register hook must agree character for character — they address the same
4625
+ // object, one by name and one by id — so a prefix change is one edit here.
4626
+ //
4627
+ // TRUNCATED TO 100, because that is Mailchimp's documented ceiling for a tag
4628
+ // name ("Tag names can be a maximum of 100 characters",
4629
+ // mailchimp.com/help/create-add-remove-tags, fetched 2026-09-12) and a segment
4630
+ // title can be 100 on its own — so the prefix pushes any title over 88 past it.
4631
+ // The limit is in their help centre and NOT in the API schema, which constrains
4632
+ // `name` to a bare string, so what the API does with an over-long name is
4633
+ // unspecified: it may reject the whole write or truncate server-side, and a
4634
+ // server-side truncation is the worse outcome because register would then look
4635
+ // for a name Mailchimp had silently changed.
4636
+ //
4637
+ // ponytail: two segments whose first 88 characters match would share one tag.
4638
+ // The upgrade is to append a short hash of the segment id, which costs the name
4639
+ // its readability in the merchant's own audience — not worth it until somebody
4640
+ // actually collides.
4641
+ const TAG_NAME_LIMIT = 100;
4642
+
4643
+ const tagName = ( title ) => ( 'Drawbridge: ' + title ).slice( 0, TAG_NAME_LIMIT );
4644
+
3970
4645
  // Mailchimp — contact sync, not a sender.
3971
4646
  var mailchimp = {
3972
4647
  // OAUTH 2, authorization code. Every url below is quoted from
@@ -4010,7 +4685,7 @@ var mailchimp = {
4010
4685
  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
4686
  description : [
4012
4687
  '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.',
4688
+ '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
4689
  '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
4690
  'Someone who unsubscribed inside Mailchimp keeps that choice: a resync only sets the status of a subscriber Mailchimp has never seen before.'
4016
4691
  ],
@@ -4023,6 +4698,7 @@ var mailchimp = {
4023
4698
  'Sign in to Mailchimp if you are not already, and choose the account to connect.',
4024
4699
  'You come back here to pick the audience your contacts should sync into.',
4025
4700
  'The connection shows Pending until you pick an audience, then Active.',
4701
+ '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
4702
  'You can remove Drawbridge at any time from the Authorized Apps page in your Mailchimp account.'
4027
4703
  ]
4028
4704
  },
@@ -4048,10 +4724,9 @@ var mailchimp = {
4048
4724
  }
4049
4725
  ],
4050
4726
  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.
4727
+ // WHAT THIS VENDOR DOES AND DOES NOT DO is the value of each hook below, not
4728
+ // a paragraph up here that goes stale the moment one of them is implemented
4729
+ // which is exactly what happened to the note this replaces.
4055
4730
  hooks : {
4056
4731
 
4057
4732
  auth : {
@@ -4135,6 +4810,45 @@ var mailchimp = {
4135
4810
 
4136
4811
  const hash = subscriberHash( email );
4137
4812
 
4813
+ // NAME AND PHONE TRAVEL AS MERGE FIELDS, because there is no other
4814
+ // way to write them. Mailchimp's member object has a `full_name`,
4815
+ // but their own schema marks it `"readOnly" : true` — it is DERIVED
4816
+ // from FNAME and LNAME
4817
+ // (api.mailchimp.com/schema/3.0/Definitions/Lists/Members/Response.json,
4818
+ // fetched 2026-09-11), so writing a whole name in one field is not
4819
+ // on offer at any price.
4820
+ //
4821
+ // ONLY THE DEFAULT TAGS. A merge tag the audience does not have is
4822
+ // refused along with the whole member request, and "merge fields
4823
+ // with the tags *|FNAME|*, *|LNAME|*, *|ADDRESS|* and *|PHONE|* are
4824
+ // present by default when an audience is created"
4825
+ // (mailchimp.com/developer/marketing/docs/merge-fields, fetched
4826
+ // 2026-09-11) — so these are the ones safe to send without
4827
+ // registering anything first.
4828
+ //
4829
+ // ADDRESS IS LEFT OUT DELIBERATELY: `lead.address` is an array of
4830
+ // free-text strings and Mailchimp's ADDRESS wants a structured
4831
+ // object (addr1/city/state/zip/country). There is no honest mapping,
4832
+ // and a malformed one takes the member request down with it.
4833
+ //
4834
+ // A lead carries ONE `name`, so the last name is everything after
4835
+ // the first whitespace run — "Ada Lovelace" splits Ada/Lovelace, and
4836
+ // a single-word name sends no LNAME rather than an empty one.
4837
+ const [ firstName, ...restOfName ] = String( lead?.name || '' ).trim().split( /\s+/ ).filter( Boolean );
4838
+
4839
+ const lastName = restOfName.join( ' ' );
4840
+
4841
+ // The raw number the entrant gave, NOT the canonical form: this is a
4842
+ // field the merchant will contact them on, and canonical values are
4843
+ // for identity matching only.
4844
+ const phone = lead?.phone?.number || null;
4845
+
4846
+ const mergeFields = {
4847
+ ...( firstName && { FNAME : firstName }),
4848
+ ...( lastName && { LNAME : lastName }),
4849
+ ...( phone && { PHONE : phone })
4850
+ };
4851
+
4138
4852
  // SUPPRESSED PEOPLE ARE SYNCED AS UNSUBSCRIBED, NEVER OMITTED.
4139
4853
  //
4140
4854
  // Omitting them means Mailchimp never learns they said no, so the
@@ -4161,14 +4875,12 @@ var mailchimp = {
4161
4875
  method : 'PUT',
4162
4876
  payload : {
4163
4877
  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 ] } }),
4878
+ // Built above. Omitted entirely when there is nothing to say, so a
4879
+ // lead with only an address does not send an empty object. The
4880
+ // Drawbridge totals Klaviyo receives still cannot travel this way
4881
+ // those are custom tags, and registering them on the chosen audience
4882
+ // is lifecycle.register's job and is not built.
4883
+ ...( Object.keys( mergeFields ).length > 0 && { merge_fields : mergeFields }),
4172
4884
  ...( suppressed && { status : 'unsubscribed' }),
4173
4885
  status_if_new : suppressed ? 'unsubscribed' : 'subscribed'
4174
4886
  },
@@ -4184,8 +4896,8 @@ var mailchimp = {
4184
4896
  // and is not built. A tag needs no setup: "If a tag that does not exist
4185
4897
  // is passed in and set as 'active', a new tag will be created"
4186
4898
  // (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.
4899
+ // fetched 2026-09-09). Nothing for the merchant to prepare the guide
4900
+ // says what appears in their audience, not what to set up first.
4189
4901
  //
4190
4902
  // ACTIVE AND INACTIVE IN ONE CALL, which is what keeps this correct
4191
4903
  // over time. Drawbridge segments are dynamic, and nothing dispatches a
@@ -4226,7 +4938,7 @@ var mailchimp = {
4226
4938
  .map( ( entry ) => entry.title )
4227
4939
  .filter( Boolean )
4228
4940
  .map( ( title ) => ({
4229
- name : 'Drawbridge: ' + title,
4941
+ name : tagName( title ),
4230
4942
  status : joined.has( title ) ? 'active' : 'inactive'
4231
4943
  }) );
4232
4944
 
@@ -4258,12 +4970,215 @@ var mailchimp = {
4258
4970
  }
4259
4971
 
4260
4972
  },
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.
4973
+ // Drawbridge sends its own notification email. A vendor answering this
4974
+ // would be a second sender, which is the arrangement the platform sender
4975
+ // replaced.
4265
4976
  email : false,
4266
- segment : false,
4977
+
4978
+ // SEGMENTS ARE SHARED, not owned by one side. Drawbridge holds the
4979
+ // membership — see the private `drawbridge` manifest, and `sync : false`
4980
+ // below — while register and remove keep a Mailchimp tag standing for each
4981
+ // Drawbridge segment, so the merchant can target one in their own audience.
4982
+ segment : {
4983
+
4984
+ // THE TAG THIS SEGMENT IS, held by id at last.
4985
+ //
4986
+ // Tags ARE static segments in Mailchimp's model — same collection, same
4987
+ // ids — so this creates one through /segments and the member write goes
4988
+ // on attaching people to it by name. Both address the same object. The
4989
+ // segment schema says it outright: "The type of segment. Static segments
4990
+ // are now known as tags"
4991
+ // (api.mailchimp.com/schema/3.0/Definitions/Lists/Segments/Response.json,
4992
+ // fetched 2026-09-12 — the root Swagger.json carries no prose, only $refs
4993
+ // into fragment files like this one).
4994
+ //
4995
+ // IDEMPOTENT ON EVERY PATH: called on create, on rename, on a connection
4996
+ // finishing its configuration, and on the backfill migration, it converges. That is what lets one hook serve
4997
+ // all four without a create-vs-update branch anywhere else.
4998
+ register : async ( { connection, context, manifest, settings, token, workflow }, { fetcher, read } = {} ) => {
4999
+
5000
+ const audience = settings?.audience;
5001
+
5002
+ if( ! audience ) return { message : 'No Mailchimp audience is chosen for this connection.', skipped : true };
5003
+
5004
+ // AS IT IS NOW, not as it was dispatched. Four places queue register
5005
+ // under one job id, so this run carries whichever of them won — and
5006
+ // applying that title would undo a rename that arrived after it.
5007
+ const segment = await currentSegment({ read, segment : context?.segment });
5008
+
5009
+ if( ! segment?.id || segment.system ) return { message : 'That segment is not one this connection publishes.', skipped : true };
5010
+
5011
+ const name = tagName( segment.title );
5012
+ const existing = segmentRowFor({ connection, segment });
5013
+
5014
+ let id = null;
5015
+
5016
+ // BY ID FIRST, because that is the only path that can rename rather
5017
+ // than orphan. A 404 means the merchant deleted the tag themselves —
5018
+ // fall through and rebuild rather than failing a step they caused.
5019
+ if( existing?.id ){
5020
+
5021
+ try {
5022
+
5023
+ const found = await api( '/lists/' + audience + '/segments/' + existing.id, { dc : settings?.dc, fetcher, token });
5024
+
5025
+ id = found?.id ?? existing.id;
5026
+
5027
+ if( found?.name !== name ){
5028
+
5029
+ await api( '/lists/' + audience + '/segments/' + existing.id, {
5030
+ dc : settings?.dc,
5031
+ fetcher,
5032
+ method : 'PATCH',
5033
+ payload : { name },
5034
+ token
5035
+ });
5036
+
5037
+ }
5038
+
5039
+ } catch ( error ){
5040
+
5041
+ if( error.status !== 404 ) throw error;
5042
+
5043
+ id = null;
5044
+
5045
+ }
5046
+
5047
+ }
5048
+
5049
+ // BY NAME BEFORE CREATING, which NARROWS the window rather than
5050
+ // closing it. Two registers can overlap — a contact joining seconds
5051
+ // after the segment was made does it, and a drift re-queue carries a
5052
+ // deliberately unique job id so it can run alongside a fresh dispatch
5053
+ // — and creating without looking leaves a second tag nothing points
5054
+ // at. Two concurrent FIRST registers can still both search, both miss
5055
+ // and both create; only one row survives, so the loser is an orphaned
5056
+ // tag rather than a wrong one. Mailchimp offers no unique-name
5057
+ // constraint to close it properly.
5058
+ //
5059
+ // tag-search matches on PREFIX, not on the exact name: "The search
5060
+ // query will be compared to each tag as a prefix, so all tags that
5061
+ // have a name starting with this field will be returned"
5062
+ // (api.mailchimp.com/schema/3.0/Parameters/PrefixTagSearchName.json,
5063
+ // fetched 2026-09-12 — the TagSearch path only $refs this parameter, and
5064
+ // the root Swagger.json carries no prose at all). So
5065
+ // "Drawbridge: VIP" answers for "Drawbridge: VIPs" too, and attaching
5066
+ // the row to the first result would point this segment at a different
5067
+ // merchant's tag. The exact name is filtered here.
5068
+ //
5069
+ // NO `count`, because the endpoint takes none: its only parameters are
5070
+ // the list id and `name`
5071
+ // (api.mailchimp.com/schema/3.0/Paths/Lists/TagSearch.json, fetched
5072
+ // 2026-09-11), unlike the audiences hook above where the default of ten
5073
+ // does bite. The response carries `total_items`, which is the one hint
5074
+ // that a page was cut — so if a merchant ever keeps more tags under one
5075
+ // Drawbridge prefix than a page holds, that field is where it shows.
5076
+ if( ! id ){
5077
+
5078
+ const search = await api( '/lists/' + audience + '/tag-search?name=' + encodeURIComponent( name ), { dc : settings?.dc, fetcher, token });
5079
+
5080
+ id = ( search?.tags || [] ).find( ( tag ) => tag?.name === name )?.id ?? null;
5081
+
5082
+ }
5083
+
5084
+ if( ! id ){
5085
+
5086
+ const created = await api( '/lists/' + audience + '/segments', {
5087
+ dc : settings?.dc,
5088
+ fetcher,
5089
+ method : 'POST',
5090
+ // STATIC WITH NO MEMBERS. The member sync attaches people by
5091
+ // name; this call only has to make the object exist. Mailchimp's
5092
+ // own wording for the empty array: "Passing an empty array will
5093
+ // create a static segment without any subscribers."
5094
+ payload : { name, static_segment : [] },
5095
+ token
5096
+ });
5097
+
5098
+ id = created?.id;
5099
+
5100
+ }
5101
+
5102
+ if( ! id ) return { message : 'Mailchimp returned no tag id.', skipped : true };
5103
+
5104
+ // THE AUDIENCE'S WEB ID, which is what the merchant's admin url is
5105
+ // keyed on — the api id in `settings.audience` does not address a page.
5106
+ // Read here rather than at page load, where the data centre is not
5107
+ // decrypted and a vendor round-trip would be on the critical path.
5108
+ const audienceDetail = await api( '/lists/' + audience + '?fields=web_id', { dc : settings?.dc, fetcher, token });
5109
+
5110
+ return {
5111
+ // A RENAME THAT ARRIVED WHILE THIS RAN was dropped by the
5112
+ // coalescing job id, so the last thing this does is look again.
5113
+ enqueues : await driftEnqueues({ applied : segment.title, read, segment, workflow }),
5114
+ events : [ {
5115
+ event : 'organization.segments',
5116
+ payload : { id : segment.id },
5117
+ room : 'organization.' + connection?.organization
5118
+ } ],
5119
+ message : 'Mailchimp is carrying this segment as the tag "' + name + '".',
5120
+ writes : segmentRowWrites({
5121
+ connection,
5122
+ data : { ...connection, settings },
5123
+ manifest,
5124
+ row : { id, type : 'tag', webId : audienceDetail?.web_id },
5125
+ segment
5126
+ })
5127
+ };
5128
+
5129
+ },
5130
+
5131
+ // THE TAG GOES WITH THE SEGMENT. Leaving it behind is the orphan this
5132
+ // whole pair exists to stop — every member would keep a label for a
5133
+ // segment that no longer exists.
5134
+ remove : async ( { connection, context, settings, token }, { fetcher } = {} ) => {
5135
+
5136
+ // NO RE-READ HERE. The segment is already deleted — the pre-image is
5137
+ // the only copy there is, and it carries the rows naming what to
5138
+ // remove.
5139
+ const segment = context?.segment;
5140
+ const existing = segmentRowFor({ connection, segment });
5141
+
5142
+ if( ! existing?.id ) return { message : 'Mailchimp was never carrying this segment.', skipped : true };
5143
+
5144
+ try {
5145
+
5146
+ await api( '/lists/' + settings?.audience + '/segments/' + existing.id, {
5147
+ dc : settings?.dc,
5148
+ fetcher,
5149
+ method : 'DELETE',
5150
+ token
5151
+ });
5152
+
5153
+ } catch ( error ){
5154
+
5155
+ // ALREADY GONE IS DONE. The merchant may have deleted it, and
5156
+ // retrying a 404 twice more achieves nothing.
5157
+ //
5158
+ // Mailchimp writes down no 404 for this call — Delete segment
5159
+ // declares a 204 and a generic problem detail and nothing else
5160
+ // (api.mailchimp.com/schema/3.0/Paths/Lists/Segments/Instance.json,
5161
+ // fetched 2026-09-11) — so this follows from their general error
5162
+ // table rather than from anything documented about deleting a
5163
+ // segment. It is still the right handling: a segment that is not
5164
+ // there is the outcome we wanted.
5165
+ if( error.status !== 404 ) throw error;
5166
+
5167
+ }
5168
+
5169
+ return {
5170
+ message : 'Mailchimp is no longer carrying this segment.',
5171
+ writes : segmentRowRemoveWrites({ connection, segment })
5172
+ };
5173
+
5174
+ },
5175
+
5176
+ // Drawbridge-side membership belongs to the private manifest.
5177
+ sync : false
5178
+
5179
+ },
5180
+ // Declined for the same reason as `email` above: Drawbridge sends its own
5181
+ // notification SMS, and a vendor answering this would be a second sender.
4267
5182
  sms : false,
4268
5183
  inbound : false,
4269
5184
  lifecycle : false,
@@ -4343,6 +5258,16 @@ var mailchimp = {
4343
5258
  'MAILCHIMP_OAUTH_CLIENT_ID',
4344
5259
  'MAILCHIMP_OAUTH_CLIENT_SECRET'
4345
5260
  ],
5261
+ // THE CHECKLIST'S RECEIPT — see drawbridge-docs reference/connection-hooks.md.
5262
+ review : {
5263
+ api : 'https://mailchimp.com/developer/marketing/guides/access-user-data-oauth-2/',
5264
+ dashboard : 'https://mailchimp.com/help/manage-tags/',
5265
+ // NO SCOPES EXIST. Mailchimp's OAuth guide describes none, and a token is
5266
+ // account-wide — so there is nothing to request and nothing to re-consent.
5267
+ scopes : false,
5268
+ content : '2026-09-11',
5269
+ verified : null
5270
+ },
4346
5271
  slug : 'mailchimp',
4347
5272
  // A grant with no audience chosen is authenticated and useless — the sync has
4348
5273
  // nowhere to put anyone — so the card must say Pending rather than Active over
@@ -4397,6 +5322,34 @@ var mailchimp = {
4397
5322
 
4398
5323
  })
4399
5324
 
5325
+ },
5326
+
5327
+ segment : {
5328
+
5329
+ // SYSTEM, so the builder never offers it and a merchant POST refuses it:
5330
+ // these fire from the segment's own lifecycle, not from a workflow
5331
+ // somebody assembled.
5332
+ //
5333
+ // The trigger is declared HERE rather than hard-coded in drawbridge-sync,
5334
+ // which is what lets a vendor arrive with its own without a queue edit.
5335
+ register : () => ({
5336
+ description : 'Keeps a matching tag in your Mailchimp audience for each Drawbridge segment, and renames it when the segment is renamed.',
5337
+ hook : 'segment.register',
5338
+ key : 'Mailchimp Segment Register',
5339
+ queue : 'connection',
5340
+ system : true,
5341
+ trigger : { event : 'segment.register', type : 'event' }
5342
+ }),
5343
+
5344
+ remove : () => ({
5345
+ description : 'Deletes the Mailchimp tag for a Drawbridge segment when the segment is deleted.',
5346
+ hook : 'segment.remove',
5347
+ key : 'Mailchimp Segment Remove',
5348
+ queue : 'connection',
5349
+ system : true,
5350
+ trigger : { event : 'segment.remove', type : 'event' }
5351
+ })
5352
+
4400
5353
  }
4401
5354
 
4402
5355
  },
@@ -4414,7 +5367,23 @@ var mailchimp = {
4414
5367
  }
4415
5368
  ]
4416
5369
  ),
4417
- title : 'Mailchimp'
5370
+ title : 'Mailchimp',
5371
+ // THE MERCHANT'S OWN ADMIN. Mailchimp's list schema states the shape outright:
5372
+ // the web_id field is "The ID used in the Mailchimp web application. View this
5373
+ // list in your Mailchimp account at
5374
+ // https://{dc}.admin.mailchimp.com/lists/members/?id={web_id}"
5375
+ // (api.mailchimp.com/schema/3.0/Definitions/Lists/Response.json, fetched
5376
+ // 2026-09-11).
5377
+ //
5378
+ // It lands on the audience's contacts, where the Drawbridge tag is one filter
5379
+ // away. Mailchimp documents no url that pre-selects a tag, so this stops one
5380
+ // click short rather than guessing at one that could break silently.
5381
+ urls : {
5382
+ segment : ( row, data ) => ( data?.settings?.dc && row?.webId
5383
+ ? 'https://' + data.settings.dc + '.admin.mailchimp.com/lists/members/?id=' + row.webId
5384
+ : null
5385
+ )
5386
+ }
4418
5387
  };
4419
5388
 
4420
5389
  // Shopify, exported from the brand kit and left as authored — the fills are the
@@ -4488,6 +5457,61 @@ const attributeLineItems = ( lineItems = [] ) => lineItems.reduce(
4488
5457
  // types them into a checkout.
4489
5458
  const generateDiscountCode = customAlphabet( '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ', 8 );
4490
5459
 
5460
+ // WHY A DISCOUNT CANNOT BACK AN ISSUED CODE, as a sentence for the merchant or
5461
+ // null when nothing is wrong. Every rule here is about the code we are about to
5462
+ // mint being redeemable by an ENTRANT we picked — which is a narrower question
5463
+ // than whether the discount is valid in general, and the reason a discount that
5464
+ // looks fine in Shopify admin can still be the wrong one to choose.
5465
+ //
5466
+ // Field semantics cited in drawbridge-shopify's discountsQuery.
5467
+ const blockedReason = ( discount ) => {
5468
+
5469
+ if( discount?.status === 'EXPIRED' ) return 'This discount has expired.';
5470
+
5471
+ // The union's "anyone" member. Everything else — named customers, a saved
5472
+ // segment, a market — restricts who may redeem, and a code we mint for an
5473
+ // entrant who is not on that list is a code that fails at checkout. Creating
5474
+ // the customer would not fix it: joining a discount's eligibility list is a
5475
+ // separate write we do not make.
5476
+ const buyers = discount?.context?.__typename;
5477
+
5478
+ if( buyers && buyers !== 'DiscountBuyerSelectionAll' ){
5479
+
5480
+ 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.';
5481
+
5482
+ }
5483
+ // Documented as possibly lagging the true count, so this can only ever
5484
+ // under-report — count >= limit means genuinely exhausted.
5485
+ if( typeof discount?.usageLimit === 'number' && discount.usageLimit > 0
5486
+ && ( discount?.asyncUsageCount || 0 ) >= discount.usageLimit ){
5487
+
5488
+ return 'This discount has reached its total usage limit.';
5489
+
5490
+ }
5491
+ return null;
5492
+
5493
+ };
5494
+
5495
+ // Usable, but about to behave in a way the merchant did not ask for. Separate
5496
+ // from blockedReason because the answer here is "go ahead, knowing this" — a
5497
+ // scheduled discount is the normal way to set up a campaign in advance, and
5498
+ // refusing it would be wrong.
5499
+ const discountWarning = ( discount ) => {
5500
+
5501
+ if( discount?.status === 'SCHEDULED' ){
5502
+
5503
+ return 'This discount hasn\'t started yet, so codes issued before it does won\'t work until then.';
5504
+
5505
+ }
5506
+ // One redemption per person, which is usually intended — but it is the
5507
+ // difference between a code that can be forwarded and one that cannot, and
5508
+ // merchants do not expect a per-entrant code to also be per-person capped.
5509
+ if( discount?.appliesOncePerCustomer ) return 'Each customer can use this discount only once.';
5510
+
5511
+ return null;
5512
+
5513
+ };
5514
+
4491
5515
  // THE USAGE METER'S EVENT HANDLE — the string that decides whether an order's
4492
5516
  // billing event bills or is silently ingested as a plain custom event. It must
4493
5517
  // equal, case-sensitively, the meter HANDLE configured on the app's plan in
@@ -4588,7 +5612,7 @@ var shopify = {
4588
5612
  description : [
4589
5613
  '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
5614
  '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.'
5615
+ '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
5616
  ],
4593
5617
  errors : {
4594
5618
  connect : {
@@ -4602,7 +5626,7 @@ var shopify = {
4602
5626
  'Open the Drawbridge listing on the Shopify App Store.',
4603
5627
  'Install the app on the store you want to connect. It opens in Shopify admin and stays there.',
4604
5628
  '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.'
5629
+ 'Come back here — the connections list updates on its own once the install finishes.'
4606
5630
  ],
4607
5631
  // Names where the link GOES rather than what it does: installing happens on
4608
5632
  // the App Store listing, and the dashboard must never imply a store can be
@@ -5125,14 +6149,27 @@ var shopify = {
5125
6149
  ? { domain : connection.source.domain, id : connection.source.id }
5126
6150
  : undefined;
5127
6151
 
6152
+ // EVERY PURCHASE GETS AN ORDER DOCUMENT, whichever way it reached us.
6153
+ //
6154
+ // A redemption used to write only a redemption row, which meant the
6155
+ // money existed on the Redemptions page and nowhere else: contact
6156
+ // totals are summed from the ORDER collection, so a real purchase by a
6157
+ // known entrant contributed nothing to their revenue and was invisible
6158
+ // to every revenue segment. `type` keeps the two kinds apart for the
6159
+ // figures that must stay causal (the fee, the Revenue page's
6160
+ // conversion column) without splitting the source of truth in two.
6161
+ const createsOrder = ! backfill && ( isConversion || Boolean( discount ) );
6162
+
5128
6163
  // MINTED HERE, because the redemption names its order and the usage
5129
6164
  // 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 );
6165
+ // id exists before either does. Minted for the redemption too, so the
6166
+ // order can name it back.
6167
+ const orderDocId = existingOrder?.id || ( createsOrder ? mintId() : null );
6168
+ const redemptionDocId = discount ? mintId() : null;
5132
6169
 
5133
6170
  const writes = [];
5134
6171
 
5135
- if( isConversion && ! backfill ){
6172
+ if( createsOrder ){
5136
6173
 
5137
6174
  writes.push({
5138
6175
  collection : 'order',
@@ -5154,8 +6191,12 @@ var shopify = {
5154
6191
  provider : { id : String( orderId ), slug : 'shopify' },
5155
6192
  purchasedAt,
5156
6193
  rate,
6194
+ // Null on a conversion that matched no code of ours; the
6195
+ // backfill branch below sets it when one arrives later.
6196
+ redemption : redemptionDocId,
5157
6197
  source,
5158
- status : 'completed'
6198
+ status : 'completed',
6199
+ type : isConversion ? 'conversion' : 'redemption'
5159
6200
  },
5160
6201
  operation : 'create'
5161
6202
  });
@@ -5164,7 +6205,14 @@ var shopify = {
5164
6205
 
5165
6206
  writes.push({
5166
6207
  collection : 'usage',
5167
- data : { $inc : { 'totals.revenue' : gross } },
6208
+ // TWO METERS, NOT ONE SUMMED. `revenue` has always meant
6209
+ // conversion revenue and is the figure the fee is charged
6210
+ // against, so redemption money gets its own key rather than
6211
+ // changing what an existing number means.
6212
+ data : { $inc : isConversion
6213
+ ? { 'totals.revenue' : gross }
6214
+ : { 'totals.redemptionRevenue' : gross }
6215
+ },
5168
6216
  operation : 'update',
5169
6217
  query : { id : org.usage }
5170
6218
  });
@@ -5175,7 +6223,15 @@ var shopify = {
5175
6223
 
5176
6224
  writes.push({
5177
6225
  collection : 'lead',
5178
- data : { $inc : { 'totals.orders' : 1 } },
6226
+ // Same grouped shape the contact carries, so a lead and the
6227
+ // contact built from it cannot be read two different ways.
6228
+ data : { $inc : {
6229
+ 'totals.orders.total' : 1,
6230
+ ...( isConversion
6231
+ ? { 'totals.orders.conversions' : 1 }
6232
+ : { 'totals.orders.redemptions' : 1 }
6233
+ )
6234
+ } },
5179
6235
  operation : 'update',
5180
6236
  options : { bypassDocumentValidation : true },
5181
6237
  query : { id : leadId }
@@ -5198,6 +6254,7 @@ var shopify = {
5198
6254
  customer,
5199
6255
  discount,
5200
6256
  gross,
6257
+ id : redemptionDocId,
5201
6258
  lead : leadId,
5202
6259
  order : orderDocId,
5203
6260
  organization : campaignOrganization,
@@ -5233,6 +6290,23 @@ var shopify = {
5233
6290
 
5234
6291
  }
5235
6292
 
6293
+ // THE BACKFILL LEG: a conversion recorded earlier, matched to one of
6294
+ // our codes now. The order already exists, so nothing above created
6295
+ // it and nothing has told it which redemption it belongs to — this
6296
+ // is the only write that closes that link. Skipped when the order
6297
+ // was created in this same run, because it was minted carrying the
6298
+ // id already.
6299
+ if( backfill && orderDocId && redemptionDocId ){
6300
+
6301
+ writes.push({
6302
+ collection : 'order',
6303
+ data : { $set : { redemption : redemptionDocId } },
6304
+ operation : 'update',
6305
+ query : { id : orderDocId }
6306
+ });
6307
+
6308
+ }
6309
+
5236
6310
  }
5237
6311
 
5238
6312
  // SHOPIFY-BILLED ORGS ARE CHARGED THROUGH SHOPIFY, keyed on the order
@@ -6020,10 +7094,24 @@ var shopify = {
6020
7094
  // The GLOBAL id is what Shopify returns and the bare id is what a
6021
7095
  // picker stores, which is why the tail is taken here rather than by
6022
7096
  // 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
- }) ),
7097
+ items : ( discounts?.edges || [] ).map( ( edge ) => {
7098
+
7099
+ const node = edge?.node?.codeDiscount || {};
7100
+
7101
+ return {
7102
+ // Null when the discount can be used, a sentence when it cannot.
7103
+ // The picker greys the row and shows this instead of hiding it:
7104
+ // a discount the merchant can see in Shopify admin, missing here
7105
+ // with no explanation, reads as a bug in us.
7106
+ blocked : blockedReason( node ),
7107
+ id : String( edge?.node?.id || '' ).split( '/' ).pop(),
7108
+ // Usable, but not in the way the merchant probably expects.
7109
+ // Shown beside the row without stopping them.
7110
+ warning : discountWarning( node ),
7111
+ title : node.title
7112
+ };
7113
+
7114
+ }),
6027
7115
  pageInfo : {
6028
7116
  endCursor : discounts?.pageInfo?.endCursor || null,
6029
7117
  hasNextPage : Boolean( discounts?.pageInfo?.hasNextPage )
@@ -6039,24 +7127,6 @@ var shopify = {
6039
7127
  },
6040
7128
  icon,
6041
7129
  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
7130
  // DRAWBRIDGE'S OWN CREDENTIALS for this vendor, as opposed to a merchant's —
6061
7131
  // what an admin types on the provider screen. The four names below are exactly
6062
7132
  // what `requires` gates on, which is the point of declaring them together: a
@@ -6102,6 +7172,14 @@ var shopify = {
6102
7172
  'SHOPIFY_APP_LISTING_URL',
6103
7173
  'SHOPIFY_APP_HANDLE'
6104
7174
  ],
7175
+ // THE CHECKLIST'S RECEIPT — see drawbridge-docs reference/connection-hooks.md.
7176
+ review : {
7177
+ api : 'https://shopify.dev/docs/api/admin-graphql',
7178
+ dashboard : 'https://help.shopify.com/en/manual/apps',
7179
+ scopes : 'https://shopify.dev/docs/api/usage/access-scopes',
7180
+ content : '2026-09-11',
7181
+ verified : null
7182
+ },
6105
7183
  slug : 'shopify',
6106
7184
  // The install is the whole configuration — Shopify hands back the shop and
6107
7185
  // there is nothing further to choose. `shop` absent means the install did not
@@ -6194,8 +7272,11 @@ var shopify = {
6194
7272
  },
6195
7273
 
6196
7274
  // 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.
7275
+ // in the builder, so they carry no usage. These two are fired by a webhook
7276
+ // arriving rather than by a workflow trigger, so they name none either —
7277
+ // and naming none is what stops a workflow being provisioned for them.
7278
+ // Declared because the routing table and the system-workflow descriptions
7279
+ // both read here.
6199
7280
  order : {
6200
7281
  record : () => ({
6201
7282
  description : 'Records an order and billing charge when a purchase is made via a Drawbridge campaign link.',
@@ -6231,7 +7312,8 @@ var shopify = {
6231
7312
  hook : 'lifecycle.health',
6232
7313
  key : 'Shopify Connection Health',
6233
7314
  queue : 'connection',
6234
- system : true
7315
+ system : true,
7316
+ trigger : { event : 'day', type : 'schedule' }
6235
7317
  })
6236
7318
  },
6237
7319
 
@@ -6298,7 +7380,34 @@ var shopify = {
6298
7380
  : []
6299
7381
  )
6300
7382
  ],
6301
- title : 'Shopify'
7383
+ title : 'Shopify',
7384
+ // THE VENDOR'S OWN ADMIN, one function per thing worth linking to. It lives
7385
+ // here rather than at the top level so a second link (a product, an order)
7386
+ // is a key in this object instead of a new manifest key nobody agreed on.
7387
+ //
7388
+ // AND HERE RATHER THAN IN drawbridge-api, which had `slug === 'shopify' &&
7389
+ // {...}` in the shared resolver — a hardcoded vendor branch in code every
7390
+ // vendor runs through, which is the arrangement these manifests exist to
7391
+ // remove.
7392
+ //
7393
+ // Never projected: the api composes connect.manage from it, and
7394
+ // resolveConnection drops the object, because a url built from settings is
7395
+ // built where the settings are already decrypted.
7396
+ urls : {
7397
+ // Undefined until a shop is linked, so the Manage button only appears on a
7398
+ // connected connection. The app handle is NAMED by `requires` and read from
7399
+ // the env its caller passes — the api's resolve() hands it the stored
7400
+ // credentials, never process.env.
7401
+ manage : ( data, env ) => {
7402
+
7403
+ const shop = data?.shop || data?.settings?.domain;
7404
+
7405
+ return shop
7406
+ ? 'https://admin.shopify.com/store/' + String( shop ).replace( '.myshopify.com', '' ) + '/apps/' + env?.SHOPIFY_APP_HANDLE
7407
+ : undefined;
7408
+
7409
+ }
7410
+ }
6302
7411
  };
6303
7412
 
6304
7413
  // Webhooks — the only connection with no third party behind it. Connecting
@@ -6355,7 +7464,7 @@ var webhook = {
6355
7464
  content : {
6356
7465
  confirm : 'Disconnecting stops Drawbridge from sending signed webhook payloads to your endpoint.',
6357
7466
  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.',
7467
+ 'Drawbridge can POST event payloads to your endpoint as activity happens in your account, so your own systems can react to it.',
6359
7468
  '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
7469
  ],
6361
7470
  excerpt : 'Sign outgoing webhook payloads with an HMAC secret to verify authenticity.',
@@ -6481,6 +7590,9 @@ var webhook = {
6481
7590
  // Gated on the encryption secret: without it the signing secret could not be
6482
7591
  // stored safely, so the connection must not be offered at all.
6483
7592
  requires : [ 'ENCRYPT_CONNECTION_SECRET' ],
7593
+ // NO THIRD PARTY AT ALL. There is no vendor reference to read, no dashboard
7594
+ // to link to and no scope to request: connecting mints a secret.
7595
+ review : false,
6484
7596
  // Outbound only. inbound.* is false because the direction is the point: we
6485
7597
  // sign and POST to the merchant's endpoint, they never call us. Every other
6486
7598
  // false follows from there being no third party to authenticate against —
@@ -7475,7 +8587,7 @@ const redactSettings = ({ slug, settings }) => {
7475
8587
  const publicConnectionKeys = Object.freeze([
7476
8588
  'actions',
7477
8589
  // 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
8590
+ // auth.type, content.redirect and the manifest's urls.manage() — the client reads
7479
8591
  // connect.type to choose entered-vs-installed, connect.redirect for the App
7480
8592
  // Store link, connect.manage for the admin deep link. It was dropped from
7481
8593
  // this list when the manifests stopped declaring it, which stripped the
@@ -7567,7 +8679,15 @@ const resolveConnection = ( item, data, env = {} ) => {
7567
8679
  // answer baked a Pending badge into Klaviyo, Mailchimp and Attentive
7568
8680
  // cards nobody had connected. Status belongs to the caller's own
7569
8681
  // read-time pass (api's applyStatus), never to projection.
7570
- .filter( ( [ key ] ) => ! [ 'auth', 'enabled', 'fields', 'hooks', 'inbound', 'provider', 'requires', 'status', 'steps', 'supports' ].includes( key ) )
8682
+ // `urls` is dropped for the same reason `provider` is: it holds raw
8683
+ // functions, and a url built from stored settings must be built where
8684
+ // those settings are already decrypted — the api calls urls.manage()
8685
+ // itself and composes the result into connect.
8686
+ //
8687
+ // `review` is dropped because it is engineering metadata — which docs
8688
+ // were read, on what day — with no client that reads it. Left in, it
8689
+ // would ride on every connection response a merchant's browser loads.
8690
+ .filter( ( [ key ] ) => ! [ 'auth', 'enabled', 'fields', 'hooks', 'inbound', 'provider', 'requires', 'review', 'status', 'steps', 'supports', 'urls' ].includes( key ) )
7571
8691
  .map( ( [ key, value ] ) => [
7572
8692
  key,
7573
8693
  ( typeof value === 'function' ? value( data, env ) : value )