Product block
A product card with a Buy now button. Its Checkout field picks where a tap actually goes. MailInApp checkout (Stripe) carries a real payment in-flow: no storefront, no separate checkout page to build, no card details ever touching MailInApp's own servers. Link to external store checkout instead sends the buyer straight to a product page on your own Shopify/WooCommerce store, for when you'd rather keep the sale (and its inventory, tax, discounts) on the store you already run.
MailInApp checkout (Stripe)
This is the block's default mode, and everything in this section applies to it specifically — see Linking out to your own store checkout below for the other mode. Connect your own Stripe account under Settings → Payments first (Stripe Connect, Standard/OAuth); every charge settles directly into that account.
Unlike a vote or a rating, a purchase can only safely complete on Stripe's own hosted checkout page, so this block has no in-email kinetic tier at all. Buy now is link-first everywhere, mail included. Tapping it always opens the hosted live view first at a confirm page (/v/[token]/act?blockId=...&blockType=product&action=buy), which re-displays the product's name and price and asks for one more tap ("Continue to payment") before it actually creates anything.
That confirm-then-POST shape is deliberate: a security scanner or corporate proxy that prefetches every link in an email must never trigger a real Stripe Checkout Session on a bare GET. Only the explicit POST from the confirm page hits POST /api/checkout. That route resolves price, name and inventory from the block's own stored configuration server-side, never from anything the client sends. It then creates a Checkout Session on the owner's connected Stripe account, writes a pending order, and 303-redirects to Stripe.
Nothing about a pending order counts as a sale yet. Only POST /api/webhooks/stripe ever moves an order to paid. It's authenticated by Stripe's own signature, the same trust model as the countdown and open-tracking endpoints' HMAC URLs, since the success-URL redirect a buyer's browser lands on is UX only and can't be trusted as proof of payment.
On that webhook, MailInApp re-checks inventory transactionally before confirming. If two buyers were racing for the last unit and both got through Stripe, the loser's charge is automatically refunded and they're emailed a notice rather than the product silently being oversold. A confirmed purchase also writes a purchase interaction event, so revenue flows through the same aggregates and project webhook pipeline every other block's responses use. See Interactions & analytics.
Fulfillment depends on the Fulfillment field:
- Digital — you enter the delivery content (a download link or code) directly on the block; the moment the webhook confirms payment, that content is emailed to the buyer automatically.
- Physical — Stripe Checkout collects a shipping address as part of the payment flow; the order sits in the project's Orders table until you click Mark fulfilled, which emails the buyer a shipped notice.
Inventory is an optional cap (0 = unlimited). Mailed HTML is static at send time, the same convention the countdown block's deadline and the meeting-link block's join window follow, so a capped product's stock isn't baked in as text. Instead the email embeds a small server-rendered stock-pill image ("3 left" / "Sold out") that's regenerated on every open, so it never shows stale availability days after the send. On the hosted live view, inventory is checked live and dynamically: once paid orders reach the cap, Buy now is replaced outright with a disabled "Sold out" control rather than just a badge next to a still-clickable button. Either way, the webhook's transactional recheck at payment time is the actual source of truth; the badge and the disabled button are UX conveniences, not the enforcement mechanism.
If you've connected a Shopify or WooCommerce store, an Import from store search box appears above these fields, listing products from that store's own catalog. It stays in sync automatically in the background, with no separate button to press. Picking a product fills in Name, Description, Image and Price for you.
When that synced product has a known storefront page (Shopify's own product handle, or WooCommerce's page URL), importing it also switches Checkout to Link to external store checkout and fills in that page as the External product page URL. An imported product is then ready to sell on your own store with nothing left to configure. A product synced before your store connection picked up page URLs is left on MailInApp checkout instead, with nothing changed underneath you.
It's a one-time copy, not a live link. Every field stays yours to edit afterward, and the price actually charged at checkout is always whatever the block currently holds, never re-fetched from the store. The fields below work the same with or without a connected store — importing is a shortcut, not a requirement.
Linking out to your own store checkout instead
Switch Checkout to Link to external store checkout and set an External product page URL — your Shopify product page, a cart permalink, a WooCommerce product page, any https:// link — to skip MailInApp's own payment flow entirely. Buy now then behaves exactly like a plain linked button or image: no confirm page, no /api/checkout, no Stripe. The click is still tracked (so it counts toward the click heatmap), and the buyer lands straight on your store to complete the purchase there.
Because MailInApp is never part of that payment, none of the MailInApp-checkout machinery above applies to an external product. There's no pending/paid order, no entry in the Orders table, no fulfillment or refund actions, no inventory enforcement or stock-status badge, and no purchase-triggered journey step. Track that revenue the way you'd track any other sale on your own store. Shopify order & checkout revenue tracking covers attributing store-side purchases back to the email that drove them.
If External product page URL is left blank, or isn't a valid https:// link, while Checkout is set to external, the block quietly falls back to behaving exactly like MailInApp checkout mode rather than rendering a dead button.
Per-product discount
Once a block was populated via Import from store (so it carries a real storeProductId, not a hand-typed URL) and Checkout is set to external, a Per-product discount toggle appears. Turn it on and set a Discount type (percentage or fixed amount off), Discount value, and Expires (days after send). The Studio canvas redraws immediately with the same discount badge and struck-through price recipients will see — no need to open Live view to check it. Right before each recipient's email renders, MailInApp mints a single-use discount code on your connected Shopify or WooCommerce store — restricted to that recipient and to just this product, not your whole store — and rewrites Buy now to a redemption link that lands the shopper back on this product's own page with the code already applied. This is independent of the project-level Discount offer on the Send page (see Discount offer): a project can use either, both, or neither, and each keeps its own cooldown so one never blocks the other. A block whose product wasn't imported from a connected store shows a note explaining why the toggle is hidden instead. Walked through end to end in Shopify product discount codes and WooCommerce product discount codes.
For Shopify stores, an optional app block can also show this same badge directly on the live product page itself, ahead of checkout — see Showing the discount on your product page (Shopify) for setup steps.
If minting fails for a recipient — no matching store customer, a token needing reconnecting, a live API hiccup — their email still sends; Buy now just falls back to the block's plain, undiscounted product link.
Configurable fields:
- Name, Description, Image — picked from your media library rather than typed as a URL, or filled in by Import from store above.
- Price and Currency (USD, EUR or GBP) — shown on the card either way, even in external mode, where they're informational only.
- Checkout — MailInApp checkout (Stripe) or Link to external store checkout.
- External product page URL — external mode only; where Buy now sends the buyer.
- Per-product discount, Discount type, Discount value, Expires (days after send) — external mode only, and only once the block's product came from Import from store.
- Fulfillment — Digital or Physical; MailInApp checkout mode only.
- Inventory — 0 for unlimited; MailInApp checkout mode only.
- Delivery content — digital-only; the download link or code sent after payment; MailInApp checkout mode only.
- Background color, padding, border, border radius, and text-style overrides for the name and description.
Examples
Subject
Back in stock: the Weekender Tote
A merchandise drop or a limited print run is the direct fit: cap Inventory at the run size and let the stock pill communicate scarcity as it depletes. See Ecommerce & product drops for the broader pattern of selling straight from a promotional send instead of routing clicks through a separate store. A digital course or ebook launch uses Digital fulfillment so the buyer gets the download link the instant payment clears, with no manual fulfillment step at all. A local business selling a physical add-on (a branded item, a seasonal box) uses Physical fulfillment and works the Orders table like a lightweight order queue, marking each one fulfilled as it ships. Use Contact buyer to open a support ticket if an address needs confirming.
What the static fallback looks like
<div style="margin:12px 0;padding:16px;border:1px solid #e5e7eb;
border-radius:8px;background-color:#ffffff">
<p style="margin:0 0 4px;font-weight:600;font-size:16px;color:#111827">
Limited edition tote bag
</p>
<p style="margin:0 0 10px;font-weight:700;font-size:18px">$29.00</p>
<img src="https://mailinapp.com/api/product-status?p=proj123&b=prod-1&s=..."
width="180" height="30" alt="" style="display:block;margin-bottom:8px" />
<a href="https://mailinapp.com/v/eyJhbGci.../act?blockId=prod-1&blockType=product&action=buy"
style="display:inline-block;padding:10px 20px;background-color:#4f46e5;
color:#ffffff;border-radius:6px;text-decoration:none;
font-size:14px;font-weight:600">
Buy now
</a>
</div>
The stock-status image only renders when an inventory cap is set; Buy now always opens the confirm page rather than posting a payment directly from inside the email.
In external checkout mode, there's no confirm page or stock badge at all — Buy now is a plain click-tracked link straight to External product page URL:
<div style="margin:12px 0;padding:16px;border:1px solid #e5e7eb;
border-radius:8px;background-color:#ffffff">
<p style="margin:0 0 4px;font-weight:600;font-size:16px;color:#111827">
Limited edition tote bag
</p>
<p style="margin:0 0 10px;font-weight:700;font-size:18px">$29.00</p>
<a href="https://mailinapp.com/v/eyJhbGci.../click?blockId=prod-1&blockType=product&dest=https%3A%2F%2Fshop.example.com%2Fproducts%2Ftote-bag"
style="display:inline-block;border:none;border-radius:6px;
background-color:#4f46e5;color:#ffffff;padding:10px 20px;
font-size:14px;font-weight:600;text-decoration:none">
Buy now
</a>
</div>
Related
- Block reference overview
- Orders, fulfillment and refunds
- Support tickets
- Interactions & analytics
- Shopify / WooCommerce — connect a store to get the Import from store picker
- Shopify order & checkout revenue tracking — attributing purchases made on your own store's checkout back to the email that sent them
- Shopify product discount codes / WooCommerce product discount codes — minting a per-product discount straight from this block