Skip to content
pulsify
Pulsify
07 / More automations Menu +

07 / More automations

More automations

Each recipe starts with what you'd say to your agent. The agent writes the code, dry-runs it and shows you the result. You decide whether to activate it. The code here is 1 good answer, not the only one: your agent adapts it to your rules.

Every recipe below is checked: the templates run against their samples in Pulsify's test suite, and so does the code written out on this page. The dry-run output is illustrative.

Pause a campaign near its daily budget

Ask your agent: "Pause any campaign that has used 90% of its daily budget. For portfolios, just tell me."

  • Needs: an Amazon Ads connection. BUDGET_USAGE subscribes when you activate it.
  • Changes: the campaign whose budget crossed the line. Portfolio events only log, because a portfolio budget isn't something an automation can change.

This is the shipped template, the code your agent starts from when it doesn't write its own:

// Budget-usage automation. Fires when a campaign or portfolio budget consumption
// crosses a 5% increment. `context.campaign` is present for campaign-scoped
// alerts; `context.portfolio` is present for portfolio-scoped alerts.
// `context.budget` carries the figures from this event.
function handle(event, context) {
  const usage = context.budget.usagePercentage;

  // Portfolio budgets are read-only: alert, don't act.
  if (context.portfolio) {
    console.log(`Portfolio ${context.portfolio.name} at ${usage}% of budget`);
    return context;
  }

  const campaign = context.campaign;

  // Pause the campaign once it has spent 90% of its daily budget.
  if (campaign && usage >= 90 && campaign.state === "enabled") {
    context.mutations.push({
      target: campaign,
      action: "update",
      payload: { state: "PAUSED" },
    });
  }

  return context;
}

Dry run against the canned sample, which reports 92.5% usage on an enabled campaign (illustrative):

{
  "mutations": [{ "target": { "type": "Campaign", "id": "…", "name": "Sample Campaign", ... }, "action": "update", "payload": { "state": "PAUSED" } }],
  "logs": []
}

Check it: list_automation_actions shows each decision and its linked mutation receipt. Inspect the receipt's outcome and the current campaign with list_ads_entities. The campaign stays paused after Amazon resets its budget the next day. If you want it back on, ask your agent for a second automation that resumes it, or resume it in Amazon Ads.

Compete for the Buy Box inside your bounds

Ask your agent: "Keep my price competitive for the Buy Box, never below my floor or above my ceiling."

  • Needs: a Seller Central connection, and a floor and ceiling on each offer, set in Seller Central or with patch_listing. ANY_OFFER_CHANGED and PRICING_HEALTH subscribe when you activate them.
  • Changes: the listing's price, nothing else.

The shipped ANY_OFFER_CHANGED template:

  • Does nothing without a floor and a ceiling, or when your offer isn't in the notification.
  • Waits while Amazon still shows the price your last accepted request replaced, for up to 40 seconds.
  • Acts only while your offer is eligible for the Buy Box, and compares it with offers on the same fulfillment channel.
  • Remembers the price gaps where it won and lost the Buy Box in context.store, and moves between them. Without a winner, it moves halfway toward your floor.
  • Never goes below the floor or above the ceiling, or above Amazon's competitive price threshold when there is 1. Skips a price Amazon already shows or a request already queued.

The PRICING_HEALTH template sets the price to Amazon's competitive price threshold, inside the same bounds, when Amazon flags your offer as uncompetitive.

ANY_OFFER_CHANGED template, 251 lines
const CONVERGENCE_THRESHOLD = 0.01; // 1% - stop optimizing when boundaries this close
const PROPAGATION_MS = 40 * 1000; // Amazon can keep reporting our old price this long after accepting a new one

function handle(event, context) {
  const listing = context.listing;
  const notification = event.Payload?.AnyOfferChangedNotification;
  const summary = notification?.Summary;
  const sellerId = notification?.SellerId;

  if (!listing.floor || !listing.ceiling) return context;

  // Find my offer
  const offers = notification?.Offers || [];
  const myOffer = offers.find((o) => o.SellerId === sellerId);

  // Can't act without our own offer in the notification
  if (!myOffer) return context;

  // Amazon still reports the price our last accepted write replaced: wait for it rather than react to it
  const propagating = propagatingPrice(listing);
  if (propagating !== null && propagating !== myOffer.ListingPrice?.Amount) {
    return context;
  }

  // Only learn when we're featured (Buy Box eligible)
  if (!myOffer.IsFeaturedMerchant) {
    return context;
  }

  const shipping = myOffer.Shipping?.Amount ?? listing.shipping;
  const myLanded = landedPrice(myOffer);
  const winning = Boolean(myOffer.IsBuyBoxWinner);
  const buyBox = buyBoxPrice(summary, listing.condition);

  // Buy box suppressed - no winner exists
  if (!winning && !buyBox) {
    // Explore toward floor to try becoming buy-box eligible
    const midpoint = (listing.floor + (myLanded - shipping)) / 2;
    queueReprice(context, round(midpoint), myOffer);
    return context;
  }

  // Every featured offer competes for the same Buy Box, whatever its fulfillment channel.
  // When winning, learn against the nearest one. When losing, against the Buy Box price.
  const buyBoxLanded = buyBox && landedPrice(buyBox);
  let compLanded = buyBoxLanded;
  if (winning) {
    const nearest = offers
      .filter((o) => o.SellerId !== sellerId && o.IsFeaturedMerchant)
      .map(landedPrice)
      .sort((a, b) => Math.abs(a - myLanded) - Math.abs(b - myLanded))[0];

    // Note: When winning with no competitor, we stay put. Jumping to ceiling would
    // cause ping-pong with suppression path. Not worth the complexity to track.
    if (nearest === undefined) return context;
    compLanded = nearest;
  }

  // Cap ceiling with competitive threshold if available
  const threshold = summary?.CompetitivePriceThreshold?.Amount;
  const ceiling = threshold
    ? Math.min(listing.ceiling, threshold)
    : listing.ceiling;
  // Pricing above a Buy Box another seller holds can't win it back
  const maxLanded = winning
    ? ceiling + shipping
    : Math.min(ceiling + shipping, buyBoxLanded);

  // Learn and suggest price. Each move goes straight to its target; once bounds converge, the price Amazon
  // echoes back is the one we'd send, so nothing is sent.
  const delta = learnBoundaries(
    context,
    myOffer,
    myLanded,
    compLanded,
    winning,
  );
  const price = clamp(
    compLanded * (1 + delta) - shipping,
    listing.floor,
    maxLanded - shipping,
  );
  queueReprice(context, round(price), myOffer);

  return context;
}

// Returns the delta to price at, relative to the competitor. Our current delta holds the price.
function learnBoundaries(context, myOffer, myLanded, compLanded, winning) {
  const { asin, condition } = context.listing;
  const key = boundaryKey(asin, condition, myOffer);
  const bounds = context.store.get(key) || { w: null, l: null, ts: null };

  // Reset stale boundaries (24h TTL handled by Redis, but also check here)
  const now = Date.now();
  if (bounds.ts && now - bounds.ts > 24 * 60 * 60 * 1000) {
    bounds.w = null;
    bounds.l = null;
  }

  // Percentage delta: negative means we're cheaper
  const delta = (myLanded - compLanded) / compLanded;

  // Update boundaries. A win above the losing boundary keeps it: that loss is our only evidence of where headroom ends.
  if (winning) {
    if (bounds.w === null || delta > bounds.w) {
      bounds.w = delta;
    }
  } else {
    if (bounds.l === null || delta < bounds.l) {
      bounds.l = delta;
    }
    // Invalidate winning boundary if we lost at a lower delta
    if (bounds.w !== null && delta <= bounds.w) {
      bounds.w = null;
    }
  }

  bounds.ts = now;
  context.store.set(key, bounds);

  if (winning) return suggestWhenWinning(delta, bounds);
  return suggestWhenLosing(delta, bounds);
}

function suggestWhenWinning(currentDelta, bounds) {
  // No losing boundary - explore upward until a loss sets one
  if (bounds.l === null) return exploreHigher(currentDelta);
  // Anti-jitter: boundaries converged (or a win came above the loss) - stay put
  if (bounds.l - bounds.w < CONVERGENCE_THRESHOLD) return currentDelta;
  // Bisect between current and losing boundary
  return (currentDelta + bounds.l) / 2;
}

function suggestWhenLosing(currentDelta, bounds) {
  if (bounds.w === null) {
    // No winning boundary - explore downward
    return exploreLower(currentDelta);
  }
  // Bisect between current and winning boundary
  return (currentDelta + bounds.w) / 2;
}

// Explore higher prices (increase percentage delta)
function exploreHigher(delta) {
  if (delta < -0.02) {
    // We're more than 2% below - halve the gap
    return delta / 2;
  } else if (delta < 0) {
    // We're slightly below - try matching
    return 0;
  } else if (delta < 0.01) {
    // We're at or slightly above - try 1% above
    return 0.01;
  } else {
    // Double our premium
    return delta * 2;
  }
}

// Explore lower prices (decrease percentage delta)
function exploreLower(delta) {
  if (delta > 0.02) {
    // We're more than 2% above - halve it
    return delta / 2;
  } else if (delta > 0) {
    // We're slightly above - try matching
    return 0;
  } else if (delta > -0.01) {
    // We're at or slightly below - try 1% below
    return -0.01;
  } else {
    // Double our discount
    return delta * 2;
  }
}

function boundaryKey(asin, condition, myOffer) {
  const channel = myOffer.IsFulfilledByAmazon ? "Amazon" : "Merchant";
  return `bounds:${asin}:${condition}:${channel}`;
}

// Note: Key uses condition but not subcondition. The algorithm learns empirically
// what delta works in each channel - subcondition advantage is captured
// in win/lose outcomes.

// The Buy Box for our condition, or null when Amazon reports none. Amazon capitalizes the condition ("New").
function buyBoxPrice(summary, condition) {
  const prices = summary?.BuyBoxPrices || [];
  return (
    prices.find(
      (p) => p.Condition?.toLowerCase() === condition?.toLowerCase(),
    ) || null
  );
}

// Listing price plus shipping, the same sum for an offer and the Buy Box
function landedPrice(offer) {
  return (offer.ListingPrice?.Amount || 0) + (offer.Shipping?.Amount || 0);
}

function clamp(value, min, max) {
  return Math.max(min, Math.min(value, max));
}

function round(value) {
  return Math.round(value * 100) / 100;
}

function queueReprice(context, price, myOffer) {
  // Amazon already shows this price, or a pending request already asks for it
  const queued = context.listing.mutations
    .filter((m) => ["queued", "submitting", "uncertain"].includes(m.status))
    .map(requestedPrice);
  if (price === myOffer.ListingPrice?.Amount || queued.includes(price)) return;

  context.mutations.push({
    target: context.listing,
    action: "update",
    payload: {
      productType: context.listing.productType || "PRODUCT",
      patches: [
        {
          op: "replace",
          path: "/attributes/purchasable_offer",
          value: [
            {
              our_price: [{ schedule: [{ value_with_tax: price }] }],
            },
          ],
        },
      ],
    },
  });
}

// The price of our latest submitted request while Amazon may still report the one before it:
// accepted under PROPAGATION_MS ago. Null otherwise.
function propagatingPrice(listing) {
  const latest = listing.mutations.find((m) => m.status === "submitted");
  if (!latest?.accepted) return null;
  const age = Date.now() - Date.parse(latest.submittedAt);
  return age < PROPAGATION_MS ? requestedPrice(latest) : null;
}

// The B2C price a request set, or null when it set none
function requestedPrice(mutation) {
  const offer = mutation.payload?.patches?.[0]?.value?.[0];
  if (!offer || (offer.audience ?? "ALL") !== "ALL") return null;
  return offer.our_price?.[0]?.schedule?.[0]?.value_with_tax ?? null;
}
PRICING_HEALTH template, 76 lines
const PROPAGATION_MS = 40 * 1000; // Amazon can keep reporting our old price this long after accepting a new one

function handle(event, context) {
  const listing = context.listing;
  // PRICING_HEALTH payloads are camelCase and nest the summary under `payload`
  // (unlike the PascalCase ANY_OFFER_CHANGED shape).
  const referencePrice = event.payload?.summary?.referencePrice;

  // Skip listings without price bounds
  if (listing.floor == null || listing.ceiling == null) {
    return context;
  }

  // Use competitive threshold as target price (most relevant for Buy Box recovery).
  // Amounts arrive as strings, so coerce to a number; keep null when absent so the
  // fallback below stays intact.
  const rawThreshold = referencePrice?.competitivePriceThreshold?.amount;
  const competitiveThreshold =
    rawThreshold == null ? null : Number(rawThreshold);

  // Fall back to current price if no threshold available
  const targetPrice = competitiveThreshold ?? listing.price;

  // Clamp between floor and ceiling
  const finalPrice = Math.max(
    listing.floor,
    Math.min(targetPrice, listing.ceiling),
  );

  // Amazon already shows this price, or a request for it has not landed yet
  if (
    finalPrice === listing.price ||
    pendingPrices(listing).includes(finalPrice)
  ) {
    return context;
  }

  // Update the listing
  queueReprice(context, finalPrice);

  return context;
}

function queueReprice(context, price) {
  context.mutations.push({
    target: context.listing,
    action: "update",
    payload: {
      productType: context.listing.productType || "PRODUCT",
      patches: [
        {
          op: "replace",
          path: "/attributes/purchasable_offer",
          value: [
            {
              our_price: [{ schedule: [{ value_with_tax: price }] }],
            },
          ],
        },
      ],
    },
  });
}

// B2C prices our requests set that Amazon may not show yet: pending, or accepted under PROPAGATION_MS ago
function pendingPrices(listing) {
  return listing.mutations
    .filter(
      (m) =>
        ["queued", "submitting", "uncertain"].includes(m.status) ||
        (m.accepted && Date.now() - Date.parse(m.submittedAt) < PROPAGATION_MS),
    )
    .map((m) => m.payload?.patches?.[0]?.value?.[0])
    .filter((offer) => offer && (offer.audience ?? "ALL") === "ALL")
    .map((offer) => offer.our_price?.[0]?.schedule?.[0]?.value_with_tax);
}

This is your pricing policy. Pulsify sends the price the code asks for, and Amazon enforces the floor and ceiling. Change the rules by asking your agent to edit the code.

Dry run against the canned sample (illustrative): 1 listing patch setting the price to 15.42.

Check it: mutations in list_listings shows whether Amazon accepted each request. The next offer event shows the price Amazon actually displays.

Pause ads when a listing is suppressed

Ask your agent: "If Amazon suppresses one of my listings from search, pause the campaigns that only advertise that product and post to our alerts channel."

  • Needs: a Seller Central connection and an Amazon Ads connection on the same account. Optionally, a webhook named alerts. LISTINGS_ITEM_ISSUES_CHANGE is always on.
  • Changes: enabled campaigns that advertise only this product.
// Pause the campaigns that advertise only this product when Amazon
// suppresses it from search. Shared campaigns stay on: they still sell
// the other products.
function handle(event, context) {
  const actions = event.Payload?.EnforcementActions || [];
  if (!actions.includes("SEARCH_SUPPRESSED")) return context;

  const listing = context.listing;
  const paused = [];
  for (const campaign of listing.campaigns) {
    if (campaign.asinCount !== 1 || campaign.state !== "enabled") continue;
    context.mutations.push({ target: campaign, action: "update", payload: { state: "PAUSED" } });
    paused.push(campaign.name);
  }

  const message = listing.asin + " is suppressed from search. Paused: " +
    (paused.join(", ") || "nothing");
  console.log(message);
  context.webhooks.alerts?.post({ text: message });
  return context;
}

It doesn't resume the campaigns when the suppression clears. Resuming blindly could turn on campaigns you paused yourself, and the store forgets after a day. Resume them in Amazon Ads, or ask your agent for a second automation that checks the issue is gone.

Dry run (illustrative): 1 pause for each single-product campaign and 1 log line.

{
  "mutations": [{ "target": { "type": "Campaign", "id": "…", "name": "Solo campaign", "asinCount": 1, ... }, "action": "update", "payload": { "state": "PAUSED" } }],
  "logs": ["B00EXAMPLE01 is suppressed from search. Paused: Solo campaign"]
}

Check it: list_automation_actions for the pauses, and Activity for the webhook delivery.

Pause a campaign that spends without selling

Ask your agent: "If a campaign spends more than 50 in 12 hours without an order, pause it."

  • Needs: an Amazon Ads connection. SP_TRAFFIC subscribes when you activate it, and conversions stay subscribed alongside it.
  • Changes: the campaign the hourly event is about.
// Pause a campaign that spent more than SPEND_LIMIT over the last
// WINDOW_HOURS hours without an attributed order. Amazon reports orders
// after the clicks that led to them, so a short window can pause a campaign
// whose orders haven't arrived yet. The context covers the last 24 hours.
const SPEND_LIMIT = 50; // your marketplace's currency, major units
const WINDOW_HOURS = 12;

function handle(event, context) {
  const campaign = context.campaign;
  if (!campaign.id || campaign.state !== "enabled") return context;

  const since = Date.parse(context.metrics.hour) - (WINDOW_HOURS - 1) * 3600000;
  const recent = (rows) => rows.filter((row) => Date.parse(row.hour) >= since);
  const spend = recent(context.hourlyTraffic)
    .reduce((sum, row) => sum + row.cost, 0);
  const orders = recent(context.hourlyConversions)
    .reduce((sum, row) => sum + row.conversions, 0);

  if (spend > SPEND_LIMIT && orders === 0) {
    context.mutations.push({ target: campaign, action: "update", payload: { state: "PAUSED" } });
    console.log(campaign.name + " spent " + spend.toFixed(2) + " in " +
      WINDOW_HOURS + "h with no orders");
  }
  return context;
}

The campaign switch applies: with it off, this automation doesn't run for that campaign. campaign.id is null until Pulsify has synced the campaign, and the code skips it until then.

Dry run: the canned sample has no hourly history, so mutations comes back empty. To see a pause, replay a real event with event_id.

Check it: list_automation_actions, then list_campaigns after the next sync.

Build a paused campaign for a listing

Ask your agent: "For this listing, build a paused Sponsored Products campaign with 1 ad group, an ad for the listing, an exact keyword and a campaign-level negative for 'free'. Leave it paused for me to review."

  • Needs: a Seller Central connection and an Amazon Ads connection on the same account, in the listing's marketplace. ANY_OFFER_CHANGED is subscribed while an automation needs it.
  • Changes: creates 1 campaign and what goes inside it. It never enables the campaign and never touches campaigns it didn't create.
// Build a paused campaign for this listing, 1 confirmed step per run.
// Nothing comes back when you ask for a creation: the next run finds the
// new entity in context and builds on it.
const PENDING = ["queued", "submitting", "uncertain"];

function waiting(parent) {
  return parent.mutations.some(function (m) { return PENDING.includes(m.status); });
}

function inside(list, key, parent) {
  return list.filter(function (entity) { return entity[key] === parent.id; });
}

function handle(event, context) {
  const listing = context.listing;
  const profile = context.advertisingProfiles[0];
  if (!profile) return context;

  const name = "Launch " + listing.asin;
  const ask = function (target, action, payload) { context.mutations.push({ target, action, payload }); };

  const campaign = listing.campaigns.find(function (c) { return c.name === name; });
  if (!campaign) {
    if (!waiting(profile)) ask(profile, "create_campaign", {
      name, state: "PAUSED", startDateTime: new Date().toISOString(),
      autoCreationSettings: { autoCreateTargets: false },
      budgets: [{ budgetType: "MONETARY", recurrenceTimePeriod: "DAILY",
        budgetValue: { monetaryBudgetValue: { monetaryBudget: { value: 10 } } } }]
    });
    return context;
  }

  const group = inside(listing.adGroups, "campaignLocalId", campaign)[0];
  if (!group) {
    if (!waiting(campaign)) ask(campaign, "create_ad_group",
      { name: "Main", state: "ENABLED", bid: { defaultBid: 0.5 } });
    return context;
  }
  if (waiting(group) || waiting(campaign)) return context;

  // The SKU arrives with the listing's first crawl.
  const sku = listing.data.listings_item?.sku;
  if (!sku) return context;

  if (inside(listing.ads, "adGroupLocalId", group).length === 0) {
    ask(group, "create_ad", { adType: "PRODUCT_AD", state: "ENABLED", creative: { productCreative: {
      productCreativeSettings: { advertisedProduct: { productIdType: "SKU", productId: sku } } } } });
  }
  if (inside(listing.targets, "adGroupLocalId", group).length === 0) {
    ask(group, "create_target", { targetType: "KEYWORD", negative: false, state: "ENABLED",
      bid: { bid: 0.5 },
      targetDetails: { keywordTarget: { keyword: "ceramic mug", matchType: "EXACT" } } });
  }
  if (inside(listing.negativeTargets, "campaignLocalId", campaign).length === 0) {
    ask(campaign, "create_target", { targetType: "KEYWORD", negative: true, state: "ENABLED",
      targetDetails: { keywordTarget: { keyword: "free", matchType: "PHRASE" } } });
  }
  return context;
}

It asks for 1 layer per run because each creation needs a confirmed parent: the campaign, then the ad group, then the ad, keyword and negative together. It waits while a request is pending, so a slow or lost reply never makes it ask twice. If Amazon rejects a step, what was already built stays, the receipt says why, and the next run asks again for whatever is still missing. Name, budget, bids and keywords are yours to set. Enable the campaign yourself, or with a second automation, once you've reviewed it.

Dry run (illustrative): on a listing with no launch campaign yet, 1 create_campaign on the advertising profile.

{
  "mutations": [{ "target": { "type": "AdvertisingProfile", "id": "…", ... }, "action": "create_campaign", "payload": { "name": "Launch B00EXAMPLE01", "state": "PAUSED", ... } }]
}

Check it: list_ads_entities with target_type: "AdvertisingProfile" shows the creation's receipt and, once Amazon confirms it, the new campaign under created.

Write your own

Every event type has a starter template, and describe_stream_type gives your agent the exact contract. The reference lists what an automation can change, and webhooks covers posting to your own systems.