@apex-inc/sdk 0.25.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -45,7 +45,7 @@ function communicationPipeline(commType) {
45
45
  function createManagementClient(cfg) {
46
46
  const root = baseUrl(cfg);
47
47
  const headers = keyHeaders(cfg);
48
- return {
48
+ const client = {
49
49
  async listExperiments() {
50
50
  const res = await fetch(`${root}/api/experiments?unified=true`, { headers });
51
51
  if (!res.ok) {
@@ -228,6 +228,29 @@ function createManagementClient(cfg) {
228
228
  * Will this edit hit a live test? Advisory only — does not stamp or mail.
229
229
  * Call before editing a page or committing.
230
230
  */
231
+ /**
232
+ * Who else is live on this page? Overlap lists plus same-page neighbors.
233
+ * Advisory only. Neighbors are different named parts on the same URL.
234
+ */
235
+ async checkConflicts(opts) {
236
+ const res = await fetch(`${root}/api/experiments/check-conflicts`, {
237
+ method: "POST",
238
+ headers,
239
+ body: JSON.stringify({
240
+ experiment_id: opts.experimentId,
241
+ surface: opts.surface,
242
+ target_url: opts.targetUrl,
243
+ target_anchor: opts.targetAnchor,
244
+ target_component: opts.targetComponent,
245
+ data_source_id: opts.dataSourceId,
246
+ primary_metric_event: opts.primaryMetricEvent,
247
+ }),
248
+ });
249
+ if (!res.ok) {
250
+ throw new Error(`Apex checkConflicts failed (${res.status}): ${await res.text().catch(() => "")}`);
251
+ }
252
+ return res.json();
253
+ },
231
254
  async checkLiveTests(opts) {
232
255
  const res = await fetch(`${root}/api/experiments/check-live-tests`, {
233
256
  method: "POST",
@@ -248,7 +271,7 @@ function createManagementClient(cfg) {
248
271
  },
249
272
  /**
250
273
  * The shop says the change was not the thing under test. Restores a
251
- * fair comparison. Cannot mark a test touched — only Apex's matcher does.
274
+ * fair comparison. Silence also leaves the numbers fair.
252
275
  */
253
276
  async markTreatmentOrthogonal(id) {
254
277
  const res = await fetch(`${root}/api/experiments/${encodeURIComponent(id)}`, {
@@ -261,6 +284,21 @@ function createManagementClient(cfg) {
261
284
  }
262
285
  return res.json();
263
286
  },
287
+ /**
288
+ * The shop says that change was the test. Marks the run Contaminated.
289
+ * Apex will not call a winner or learn from it. Offer a new run.
290
+ */
291
+ async markTreatmentContaminated(id) {
292
+ const res = await fetch(`${root}/api/experiments/${encodeURIComponent(id)}`, {
293
+ method: "PATCH",
294
+ headers,
295
+ body: JSON.stringify({ treatment_touch: { status: "touched" } }),
296
+ });
297
+ if (!res.ok) {
298
+ throw new Error(`Apex markTreatmentContaminated failed (${res.status}): ${await res.text().catch(() => "")}`);
299
+ }
300
+ return res.json();
301
+ },
264
302
  async activateExperiment(id) {
265
303
  const res = await fetch(`${root}/api/experiments`, {
266
304
  method: "PATCH",
@@ -1525,5 +1563,286 @@ function createManagementClient(cfg) {
1525
1563
  },
1526
1564
  },
1527
1565
  };
1566
+ // ─── Experiments (Mobile Measurement P1 regroup, 2026-09-14) ─────────
1567
+ //
1568
+ // The experiment methods used to sit flat on the client next to
1569
+ // `adoption.*`, `communications.*`, `conversions.*`. They now live under
1570
+ // `experiments.*` like every other surface. The flat names stay as
1571
+ // deprecated aliases so nothing that already calls
1572
+ // `apexClient.listExperiments()` breaks on a minor bump.
1573
+ const experiments = {
1574
+ /** List the workspace's experiments (unified: web, code, comm, journey). */
1575
+ list: client.listExperiments,
1576
+ /** One experiment by id. */
1577
+ get: client.getExperiment,
1578
+ /** Per-arm results for an experiment. */
1579
+ getResults: client.getExperimentResults,
1580
+ /** Start serving a draft. */
1581
+ activate: client.activateExperiment,
1582
+ /** Make the winner permanent. */
1583
+ promote: client.promoteExperiment,
1584
+ /** Server-side arm for a visitor. */
1585
+ assignVariant: client.assignVariant,
1586
+ /** Would a new treatment collide with a running test? */
1587
+ checkConflicts: client.checkConflicts,
1588
+ /** Is anything live on these files / comms / journeys / milestones? */
1589
+ checkLiveTests: client.checkLiveTests,
1590
+ markTreatmentOrthogonal: client.markTreatmentOrthogonal,
1591
+ markTreatmentContaminated: client.markTreatmentContaminated,
1592
+ /**
1593
+ * Ad holdout (Mobile Measurement P3a): hold a share of the workspace's
1594
+ * known people back from the ads on one network's ad sets and measure
1595
+ * the installs the ads actually cause. Creates a DRAFT; call
1596
+ * `activate` to draw the list. Nothing counts until Apex confirms the
1597
+ * do-not-show list is on every ad set (`verifyAdExclusion`).
1598
+ *
1599
+ * Wraps `POST /api/experiments` with surface `paid_channel`, the same
1600
+ * route the dashboard wizard and the MCP `create_experiment` use.
1601
+ * Admin-only, and the workspace must have Ad holdouts switched on.
1602
+ */
1603
+ async createAdHoldout(params) {
1604
+ const { name, hypothesis, runtimeDays, beliefId, predictionMagnitude, ...paidChannel } = params;
1605
+ const attachMode = paidChannel.attachMode ?? (paidChannel.network === "meta" ? "apex" : "manual");
1606
+ const days = runtimeDays ?? 28;
1607
+ const res = await fetch(`${root}/api/experiments`, {
1608
+ method: "POST",
1609
+ headers,
1610
+ body: JSON.stringify({
1611
+ surface: "paid_channel",
1612
+ name,
1613
+ hypothesis: hypothesis ??
1614
+ `The ads on ${paidChannel.adSetIds.length === 1 ? "this ad set" : `these ${paidChannel.adSetIds.length} ad sets`} cause installs that would not have happened without them.`,
1615
+ createdFrom: "sdk",
1616
+ status: "draft",
1617
+ paidChannel: {
1618
+ network: paidChannel.network,
1619
+ adAccountId: paidChannel.adAccountId,
1620
+ adSetIds: paidChannel.adSetIds,
1621
+ holdoutPercent: paidChannel.holdoutPercent,
1622
+ attachMode,
1623
+ washoutDays: paidChannel.washoutDays ?? 3,
1624
+ ...(paidChannel.populationSegmentId ? { populationSegmentId: paidChannel.populationSegmentId } : {}),
1625
+ ...(paidChannel.campaignNames?.length ? { campaignNames: paidChannel.campaignNames } : {}),
1626
+ },
1627
+ decisionWindow: { unit: "days", value: days, startFrom: "assignment" },
1628
+ ...(beliefId ? { beliefId } : {}),
1629
+ ...(predictionMagnitude
1630
+ ? { prediction: { direction: "increase", magnitude: predictionMagnitude, provenance: "merchant" } }
1631
+ : {}),
1632
+ }),
1633
+ });
1634
+ if (!res.ok)
1635
+ throw new Error(`Apex experiments.createAdHoldout failed (${res.status}): ${await res.text().catch(() => "")}`);
1636
+ return res.json();
1637
+ },
1638
+ /**
1639
+ * Ask Apex to check right now whether the do-not-show list is on every
1640
+ * ad set the holdout covers, and record the answer. Rate-limited: a
1641
+ * recent check returns the last reading (`skipped: true`) instead of
1642
+ * re-reading the network. Wraps
1643
+ * `GET /api/experiments/{id}/ad-exclusion/verify`.
1644
+ */
1645
+ async verifyAdExclusion(experimentId) {
1646
+ const res = await fetch(`${root}/api/experiments/${encodeURIComponent(experimentId)}/ad-exclusion/verify`, { headers });
1647
+ const json = (await res.json().catch(() => null));
1648
+ if (res.status === 429 && json?.data) {
1649
+ return { ...json.data, skipped: true, retryAfterSeconds: json.retryAfterSeconds };
1650
+ }
1651
+ if (!res.ok || !json?.data) {
1652
+ throw new Error(`Apex experiments.verifyAdExclusion failed (${res.status}): ${json?.error ?? ""}`);
1653
+ }
1654
+ return json.data;
1655
+ },
1656
+ /**
1657
+ * What Apex would change on the holdout's ad sets, without changing it:
1658
+ * each ad set's exclusion list before and after, the one id added, and
1659
+ * whether the workspace's standing allowance for Apex to edit ad-set
1660
+ * targeting is on file. Any member may call it; it writes nothing.
1661
+ * Meta only. Wraps `GET /api/experiments/{id}/ad-exclusion/attach`.
1662
+ */
1663
+ async previewAdExclusion(experimentId) {
1664
+ const res = await fetch(`${root}/api/experiments/${encodeURIComponent(experimentId)}/ad-exclusion/attach`, { headers });
1665
+ const json = (await res.json().catch(() => null));
1666
+ if (!res.ok || !json?.data) {
1667
+ throw new Error(`Apex experiments.previewAdExclusion failed (${res.status}): ${json?.error ?? ""}`);
1668
+ }
1669
+ return json.data;
1670
+ },
1671
+ /**
1672
+ * Apex puts the held-back list on the holdout's ad sets (Mobile
1673
+ * Measurement P3b, Meta only). Adds exactly one exclusion per ad set;
1674
+ * every other targeting key is left as read, and Apex reads back to
1675
+ * confirm. Admin-only. The workspace must have allowed Apex to edit
1676
+ * ad-set targeting once: pass `acknowledge: true` on the first call,
1677
+ * after showing the preview; later calls ride on the allowance. Without
1678
+ * it the call fails with 409 `needs_targeting_ack` and nothing is edited.
1679
+ * Not billed. Wraps `POST /api/experiments/{id}/ad-exclusion/attach`.
1680
+ */
1681
+ async attachAdExclusion(experimentId, opts = {}) {
1682
+ const res = await fetch(`${root}/api/experiments/${encodeURIComponent(experimentId)}/ad-exclusion/attach`, { method: "POST", headers, body: JSON.stringify(opts.acknowledge ? { acknowledge: true } : {}) });
1683
+ const json = (await res.json().catch(() => null));
1684
+ if (!res.ok || !json?.data) {
1685
+ throw new Error(`Apex experiments.attachAdExclusion failed (${res.status}${json?.code ? ` ${json.code}` : ""}): ${json?.error ?? ""}`);
1686
+ }
1687
+ return json.data;
1688
+ },
1689
+ /**
1690
+ * Take Apex's exclusion back off the ad sets and delete the held-back
1691
+ * list. Only removes what Apex added; a list the shop attached by hand
1692
+ * is refused with 409 `not_apex_attached`. Admin-only. Ending, pausing
1693
+ * or archiving the holdout does this on its own; call it to undo an
1694
+ * attach early. Wraps `POST /api/experiments/{id}/ad-exclusion/detach`.
1695
+ */
1696
+ async detachAdExclusion(experimentId) {
1697
+ const res = await fetch(`${root}/api/experiments/${encodeURIComponent(experimentId)}/ad-exclusion/detach`, { method: "POST", headers });
1698
+ const json = (await res.json().catch(() => null));
1699
+ if (!res.ok || !json?.data) {
1700
+ throw new Error(`Apex experiments.detachAdExclusion failed (${res.status}${json?.code ? ` ${json.code}` : ""}): ${json?.error ?? ""}`);
1701
+ }
1702
+ return json.data;
1703
+ },
1704
+ };
1705
+ // ─── Apex Links ───────────────────────────────────────────────────────
1706
+ //
1707
+ // Trackable links (short URL, store routing, deferred deep link, UTMs).
1708
+ // Wraps `/api/mobile/links`, the same route the dashboard and MCP use.
1709
+ const links = {
1710
+ /** List the workspace's Apex Links. */
1711
+ async list(opts) {
1712
+ const qs = new URLSearchParams();
1713
+ if (opts?.ownerType)
1714
+ qs.set("ownerType", opts.ownerType);
1715
+ if (opts?.ownerId)
1716
+ qs.set("ownerId", opts.ownerId);
1717
+ if (opts?.enabled !== undefined)
1718
+ qs.set("enabled", String(opts.enabled));
1719
+ if (opts?.archived !== undefined)
1720
+ qs.set("archived", String(opts.archived));
1721
+ if (opts?.limit !== undefined)
1722
+ qs.set("limit", String(opts.limit));
1723
+ const q = qs.toString();
1724
+ const res = await fetch(`${root}/api/mobile/links${q ? `?${q}` : ""}`, { headers });
1725
+ if (!res.ok)
1726
+ throw new Error(`Apex links.list failed (${res.status}): ${await res.text().catch(() => "")}`);
1727
+ const json = (await res.json());
1728
+ return json.links ?? [];
1729
+ },
1730
+ /**
1731
+ * Create an Apex Link. `slug` becomes the short path; `destinationUrl`
1732
+ * is where the web falls back. Store fallbacks are derived from the
1733
+ * workspace's app config unless given.
1734
+ */
1735
+ async create(params) {
1736
+ const res = await fetch(`${root}/api/mobile/links`, {
1737
+ method: "POST", headers, body: JSON.stringify(params),
1738
+ });
1739
+ if (!res.ok)
1740
+ throw new Error(`Apex links.create failed (${res.status}): ${await res.text().catch(() => "")}`);
1741
+ return res.json();
1742
+ },
1743
+ };
1744
+ // ─── Mobile Measurement ──────────────────────────────────────────────
1745
+ //
1746
+ // Install attribution, SKAN values and Ad holdouts land here as the
1747
+ // Mobile Measurement plan ships (P2–P3a).
1748
+ const mobile = {
1749
+ /**
1750
+ * SKAdNetwork conversion values: what Apple's 0–63 numbers mean for
1751
+ * this app. Wraps `/api/mobile/skan/schema`, the same route the
1752
+ * dashboard's "Say what Apple's numbers mean" step saves through.
1753
+ * The Capacitor plugin reads the anonymous `/resolve` twin of this
1754
+ * route on the device.
1755
+ */
1756
+ skan: {
1757
+ /**
1758
+ * Read the schema. `mobileAppId` picks an app; omit it for the
1759
+ * workspace's first app. `schema` is null until the shop names its
1760
+ * values.
1761
+ */
1762
+ async getSchema(opts) {
1763
+ const q = opts?.mobileAppId ? `?mobileAppId=${encodeURIComponent(opts.mobileAppId)}` : "";
1764
+ const res = await fetch(`${root}/api/mobile/skan/schema${q}`, { headers });
1765
+ if (!res.ok)
1766
+ throw new Error(`Apex mobile.skan.getSchema failed (${res.status}): ${await res.text().catch(() => "")}`);
1767
+ const json = (await res.json());
1768
+ return json.data;
1769
+ },
1770
+ /**
1771
+ * Replace the schema. Workspace admin (or an `apex_sk_` key). Range,
1772
+ * enum and one-value-per-event-per-window checks run server-side;
1773
+ * a 400 carries `problems` with a path per fault. `updatedAt` is
1774
+ * stamped by Apex, so you may omit it.
1775
+ */
1776
+ async setSchema(params) {
1777
+ const res = await fetch(`${root}/api/mobile/skan/schema`, {
1778
+ method: "PUT", headers, body: JSON.stringify(params),
1779
+ });
1780
+ if (!res.ok)
1781
+ throw new Error(`Apex mobile.skan.setSchema failed (${res.status}): ${await res.text().catch(() => "")}`);
1782
+ const json = (await res.json());
1783
+ return json.data;
1784
+ },
1785
+ },
1786
+ };
1787
+ // ─── Beliefs ─────────────────────────────────────────────────────────
1788
+ //
1789
+ // What the shop believes and what its experiments have said about it.
1790
+ // `suggestions` is the "Worth writing down" rail: settled experiments
1791
+ // whose verdict passes the belief gate (decisive, exclusion attached the
1792
+ // whole window, no SRM, no guardrail breach, both arms over the events
1793
+ // floor) and have not been written down yet. Statistical only.
1794
+ const beliefs = {
1795
+ /** The workspace's beliefs. */
1796
+ async list() {
1797
+ const res = await fetch(`${root}/api/beliefs`, { headers });
1798
+ if (!res.ok)
1799
+ throw new Error(`Apex beliefs.list failed (${res.status}): ${await res.text().catch(() => "")}`);
1800
+ const json = (await res.json());
1801
+ return Array.isArray(json) ? json : json.data;
1802
+ },
1803
+ suggestions: {
1804
+ /**
1805
+ * Settled experiments worth writing down as a belief. Pass
1806
+ * `experimentId` for one experiment; then `blocker` says in one word
1807
+ * why it is not offered (`not_decisive`, `srm`, `guardrail_breach`,
1808
+ * `exclusion_not_attached`, `results_frozen`, `events_floor`,
1809
+ * `already_written`).
1810
+ */
1811
+ async list(opts) {
1812
+ const qs = new URLSearchParams();
1813
+ if (opts?.experimentId)
1814
+ qs.set("experiment_id", opts.experimentId);
1815
+ if (opts?.max !== undefined)
1816
+ qs.set("max", String(opts.max));
1817
+ const q = qs.toString();
1818
+ const res = await fetch(`${root}/api/experiments/belief-suggestions${q ? `?${q}` : ""}`, { headers });
1819
+ if (!res.ok)
1820
+ throw new Error(`Apex beliefs.suggestions.list failed (${res.status}): ${await res.text().catch(() => "")}`);
1821
+ const json = (await res.json());
1822
+ return json.data;
1823
+ },
1824
+ /**
1825
+ * Write one down. Creates a Belief with the experiment as its
1826
+ * evidence and `generatedFromExperimentId` set; the evidence state
1827
+ * starts at `one_study`. `statement` is your edit of the sentence;
1828
+ * the numbers stay Apex's. Workspace admin (or an `apex_sk_` key).
1829
+ * The gate re-runs here: a 422 carries `blocker`; a 409 means it was
1830
+ * already written and hands back that belief.
1831
+ */
1832
+ async accept(params) {
1833
+ const res = await fetch(`${root}/api/experiments/belief-suggestions/accept`, {
1834
+ method: "POST", headers, body: JSON.stringify(params),
1835
+ });
1836
+ const json = (await res.json().catch(() => null));
1837
+ if (res.status === 409 && json?.data)
1838
+ return json.data;
1839
+ if (!res.ok || !json?.data) {
1840
+ throw new Error(`Apex beliefs.suggestions.accept failed (${res.status}${json?.blocker ? ` ${json.blocker}` : ""}): ${json?.error ?? ""}`);
1841
+ }
1842
+ return json.data;
1843
+ },
1844
+ },
1845
+ };
1846
+ return { ...client, experiments, links, mobile, beliefs };
1528
1847
  }
1529
1848
  //# sourceMappingURL=management.js.map