Hogsend is brand new.Chat to Doug
Recipes — E-commerce

E-commerce

Carts, orders, deliveries, and restocks — purchase-stream flows end to end.

E-commerce recipes ride the purchase stream: cart, checkout, delivery, restock. They wait for the order to complete and only send when it does not, so a shopper never gets a 'you left something behind' after they have paid.

Every recipe below is the working code — copy it straight in, or open the full write-up for the wiring and the reasoning.

4 recipes

The recipes

Abandoned cart recovery

Wait for the purchase, remind twice, and stop the instant the order lands.

waitForEvent is the branch (did they buy yet?); exitOn is the guarantee (a purchase at any point kills the run, even mid-sleep).

Full write-up
src/journeys/abandoned-cart.ts
export const abandonedCart = defineJourney({
  meta: {
    id: "abandoned-cart",
    name: "E-commerce — abandoned cart",
    enabled: true,
    trigger: {
      event: Events.CHECKOUT_STARTED,
      // carts under $25 never enter the journey at all
      where: (b) => b.prop("cart_value").gte(25),
    },
    entryLimit: "once_per_period",
    entryPeriod: days(7),
    suppress: hours(12),
    exitOn: [{ event: Events.CHECKOUT_COMPLETED }],
  },

  run: async (user, ctx) => {
    // Give the purchase four hours to complete on its own.
    const first = await ctx.waitForEvent({
      event: Events.CHECKOUT_COMPLETED,
      timeout: hours(4),
    });
    if (!first.timedOut) return; // they bought — nothing to recover

    if (!(await ctx.guard.isSubscribed())) return;

    await sendEmail({
      to: user.email,
      userId: user.id,
      journeyStateId: user.stateId,
      template: Templates.ECOMMERCE_CART_REMINDER,
      subject: "You left items in your cart",
      journeyName: user.journeyName,
      props: {
        cartId: String(user.properties.cart_id ?? ""),
        cartValue: Number(user.properties.cart_value ?? 0),
      },
    });

    // One more day. exitOn still covers a purchase mid-wait.
    const second = await ctx.waitForEvent({
      event: Events.CHECKOUT_COMPLETED,
      timeout: days(1),
    });
    if (!second.timedOut) return;

    // Land the last call at 09:30 in the shopper's own timezone.
    await ctx.sleepUntil(ctx.when.nextLocal("09:30"));
    if (!(await ctx.guard.isSubscribed())) return;

    await sendEmail({
      to: user.email,
      userId: user.id,
      journeyStateId: user.stateId,
      template: Templates.ECOMMERCE_CART_LAST_CALL,
      subject: "Your cart is about to expire",
      journeyName: user.journeyName,
      props: {
        cartId: String(user.properties.cart_id ?? ""),
        cartValue: Number(user.properties.cart_value ?? 0),
      },
    });
  },
});

Post-purchase series

Receipt, a durable wait for delivery, product onboarding, and a hand-off to the review ask.

waitForEvent answers "has it arrived?"; the timeout branch converts silence into the same delivery.confirmed event, tagged source: "assumed".

Full write-up
src/journeys/post-purchase-series.ts
export const postPurchaseSeries = defineJourney({
  meta: {
    id: "post-purchase-series",
    name: "E-commerce — post-purchase series",
    enabled: true,
    trigger: { event: Events.ORDER_COMPLETED },
    entryLimit: "once_per_period",
    entryPeriod: days(14),
    suppress: hours(12),
    // a return cancels the arc — even mid-wait
    exitOn: [{ event: Events.ORDER_REFUNDED }],
  },

  run: async (user, ctx) => {
    const orderId = String(user.properties.order_id ?? "");
    const productName = String(user.properties.product_name ?? "your order");

    // Day 0 — the receipt is the arc's first touch.
    await sendEmail({
      to: user.email,
      userId: user.id,
      journeyStateId: user.stateId,
      template: Templates.ECOMMERCE_RECEIPT,
      subject: `Order confirmed — ${productName}`,
      journeyName: user.journeyName,
      props: { orderId, productName },
    });

    // Wait for the carrier to report delivery; ten days is the proxy
    // window when no signal ever arrives.
    const delivery = await ctx.waitForEvent({
      event: Events.DELIVERY_CONFIRMED,
      timeout: days(10),
    });

    if (delivery.timedOut) {
      // No carrier feed, or a lost webhook. Assume delivered so the rest
      // of the purchase stream keys off one event name.
      await ctx.trigger({
        event: Events.DELIVERY_CONFIRMED,
        userId: user.id,
        properties: {
          order_id: orderId,
          product_name: productName,
          source: "assumed",
        },
      });
    }

    if (!(await ctx.guard.isSubscribed())) return;

    // The product is in their hands — help them get value from it.
    await sendEmail({
      to: user.email,
      userId: user.id,
      journeyStateId: user.stateId,
      template: Templates.ECOMMERCE_PRODUCT_ONBOARDING,
      subject: `Getting the most from ${productName}`,
      journeyName: user.journeyName,
      props: { orderId, productName },
    });
  },
});

Review request

Five stars in the email, a durable wait for the rating, and a branch on the score.

waitForEvent returns the answer's payload — rating ≤ 3 flags support via ctx.trigger and sends nothing; ≥ 4 gets the public-review ask.

Full write-up
src/journeys/review-request.ts
export const reviewRequest = defineJourney({
  meta: {
    id: "review-request",
    name: "E-commerce — review request",
    enabled: true,
    trigger: { event: Events.DELIVERY_CONFIRMED },
    // one ask per shopper per month, however many orders deliver
    entryLimit: "once_per_period",
    entryPeriod: days(30),
    suppress: hours(24),
    // a refund cancels the ask. REVIEW_RATED must never appear here —
    // the journey awaits it below.
    exitOn: [{ event: Events.ORDER_REFUNDED }],
  },

  run: async (user, ctx) => {
    const orderId = String(user.properties.order_id ?? "");
    const productName = String(user.properties.product_name ?? "your order");

    // Three days of actually using the product before asking.
    await ctx.sleep({ duration: days(3) });
    if (!(await ctx.guard.isSubscribed())) return;

    await sendEmail({
      to: user.email,
      userId: user.id,
      journeyStateId: user.stateId,
      template: Templates.ECOMMERCE_REVIEW_REQUEST,
      subject: `How is ${productName} working out?`,
      journeyName: user.journeyName,
      props: { orderId, productName },
    });

    // The stars in the email are semantic links — a click fires
    // review.rated { rating }. lookback covers the send→wait gap.
    const answer = await ctx.waitForEvent({
      event: Events.REVIEW_RATED,
      timeout: days(7),
      lookback: minutes(30),
    });

    if (answer.timedOut) return; // no answer — leave them be

    const rating = Number(answer.properties?.rating ?? 0);

    if (rating <= 3) {
      // Unhappy — flag support instead of asking for a public review.
      await ctx.trigger({
        event: Events.REVIEW_NEEDS_FOLLOWUP,
        userId: user.id,
        properties: { order_id: orderId, rating, source: "review-request" },
      });
      return;
    }

    if (!(await ctx.guard.isSubscribed())) return;

    // Happy — now ask for the public review.
    await sendEmail({
      to: user.email,
      userId: user.id,
      journeyStateId: user.stateId,
      template: Templates.ECOMMERCE_REVIEW_PUBLIC_ASK,
      subject: "Would you share that in a review?",
      journeyName: user.journeyName,
      props: { orderId, productName, rating },
    });
  },
});

Back-in-stock notifications

Per-product opt-in lists, a signed restock webhook, and an idempotent one-shot broadcast.

HMAC-verified, Zod-validated, deduped by the provider's delivery id — and quantity 0 restocks are dropped at the door with return null.

Full write-up
src/webhook-sources/inventory.ts
export const inventorySource = defineWebhookSource({
  meta: {
    id: "inventory",
    name: "Inventory",
    description: "Restock signals from the warehouse system.",
  },
  auth: {
    type: "signature", // fails closed when the secret is unset
    scheme: "hmac-hex",
    envKey: "INVENTORY_WEBHOOK_SECRET",
    header: "x-signature",
  },
  schema: restockSchema,
  async transform(payload) {
    if (payload.type !== "restock" || payload.quantity === 0) return null;

    return {
      event: Events.PRODUCT_RESTOCKED, // the routing key
      userEmail: "", // a system event — no user attached
      eventProperties: {
        sku: payload.sku,
        product_name: payload.product_name,
        quantity: payload.quantity,
        source: "inventory",
      },
      idempotencyKey: payload.delivery_id, // a redelivery is a no-op
    };
  },
});

Copy a recipe into your app

Paste any recipe straight into your codebase, or scaffold a fresh app with create-hogsend and build from there.

Free to self-host · One scaffold command · No per-contact billing

terminal
pnpm dlx create-hogsend@latest my-app