product.purchased

Fires once per delivery created by the post-payment fulfilment flow — the moment a buyer successfully completes a checkout for a Storlaunch product and the delivery row is written. This is the canonical "fulfil this" event for Storlaunch merchants: digital downloads, licence keys, and physical-shipment kick-offs all run through it.

One purchase, one fulfilment. For multi-product cart checkouts, you receive one event per line item (each with its own deliveryId) referencing the same sessionId.

When it fires

In the fulfilCheckoutSession server-side flow, after:

  1. The checkout session transitioned to completed (via Plugipay webhook ingestion, manual confirmation, or PayPal capture).
  2. The delivery row was successfully written for this product line.
  3. For physical products, the Fulkruma shipment create succeeded (or failed-non-blocking).

It does not fire if any of those steps errored — partial fulfilment leaves the line in pending_fulfilment and emits no event. Use GET /v1/storefront/deliveries to reconcile if you suspect drops.

Payload

{
  "id": "evt_01HX...",
  "type": "product.purchased",
  "createdAt": "2026-05-13T10:43:22Z",
  "accountId": "acc_01HX...",
  "data": {
    "deliveryId": "del_01HX...",
    "productId": "prod_01HX...",
    "sessionId": "cs_01HX...",
    "licenseKey": "STORLAUNCH-AB12-CD34-EF56-GH78",
    "hasShipping": false
  }
}
  • deliveryId — the row in Deliveries. Hit GET /v1/storefront/deliveries/:id for download URL + expiry.
  • productId — the product that was purchased.
  • sessionId — the cs_… checkout session. Use it to dedupe and to look up buyer metadata (customerEmail, metadata.orderId).
  • licenseKey — only set for type: license products. null for digital downloads and physical goods.
  • hasShippingtrue for physical products. Means a Fulkruma shipment has been created and the buyer will receive courier emails.

The payload deliberately stays slim; fetch the delivery (or session, or product) by ID for the full picture.

Handler examples

// Node
import { verifyWebhook } from '@forjio/storlaunch/webhooks';
import { Storlaunch } from '@forjio/storlaunch';

const client = new Storlaunch({ apiKey: process.env.STORLAUNCH_API_KEY });

app.post('/webhooks/storlaunch', async (req, res) => {
  const event = verifyWebhook(req.rawBody, req.headers['x-storlaunch-signature'], process.env.STORLAUNCH_WEBHOOK_SECRET);
  if (event.type === 'product.purchased') {
    const { deliveryId, productId, sessionId } = event.data;
    const delivery = await client.deliveries.retrieve(deliveryId);
    await crm.orders.markPaid(sessionId, { deliveryId, downloadUrl: delivery.downloadUrl });
    await analytics.track('Purchase', { productId, sessionId, value: delivery.amount });
  }
  res.status(200).end();
});
# Python
if event["type"] == "product.purchased":
    d = event["data"]
    delivery = client.deliveries.retrieve(d["deliveryId"])
    crm.orders.mark_paid(d["sessionId"], delivery_id=d["deliveryId"], download_url=delivery.get("downloadUrl"))
    analytics.track("Purchase", product_id=d["productId"], session_id=d["sessionId"], value=delivery["amount"])
// Go
if event.Type == "product.purchased" {
    var d struct {
        DeliveryID string `json:"deliveryId"`
        ProductID  string `json:"productId"`
        SessionID  string `json:"sessionId"`
        HasShipping bool  `json:"hasShipping"`
    }
    _ = json.Unmarshal(event.Data, &d)
    delivery, _ := client.Deliveries.Retrieve(ctx, d.DeliveryID)
    crm.MarkPaid(ctx, d.SessionID, delivery)
}

What to do

  • Provision. Grant the buyer access to whatever they bought — this is the moment to flip their license, send your own welcome email, or ping fulfilment Slack.
  • Update analytics. Send the Purchase event to Meta CAPI / TikTok / GA. Storlaunch already does this server-side via the pixel-cookies stashed on the session, but if you run your own pipeline, fire here.
  • Dedupe. Use event.id as your event-store key. Receiving the same event twice is normal under at-least-once delivery.

Common pitfalls

  • Treating product.purchased as full order data. It's a notification, not a payload. Fetch the delivery or session for amount, customer email, and metadata.
  • Doing the work twice. Webhooks are at-least-once; dedupe before fulfilling. A double-fire causing a double-deliver of a download or shipment is a really bad bug.
  • Ignoring multi-product carts. A cart with 3 line items fires 3 events. Don't assume one per session.
  • Skipping hasShipping. When true, the buyer is about to receive courier emails from Fulkruma — don't also send your own "your order has shipped" message until you have the actual tracking number (subscribe on Fulkruma side for that).
  • Caching the download URL forever. The delivery's downloadUrl is a time-limited signed URL. Re-resolve each time the buyer asks.

Related events

  • checkout.completed — fires once per session, before fulfilment. Use it for the "money in" hook.

Next