Stripe + Gelato print-on-demand, end to end
Let users buy a real printed poster of what they made: render at print resolution, host it, charge with Stripe, and create the Gelato order from the webhook, not the browser.
The symptom
CollageCraft lets someone design a collage and then buy it as a real A3 poster or a canvas, printed and shipped to their door, with nobody manually fulfilling anything. “Add a print button” sounds like one API call. It is not. It is a pipeline with four moving parts, and the first version I built had the steps in the wrong place: the print order was created before the payment cleared, and Gelato rejected it over a single field name.
What was actually happening
Print-on-demand is render then host then pay then fulfil, and three of those four steps have a non-obvious failure mode:
- The artwork is an SVG on screen. Send that straight to print and it comes out soft, a printer needs a high-resolution raster, not your display-sized vector.
- Gelato fetches the image by URL. It cannot reach a blob in your app’s memory or a signed URL that expires in 60 seconds.
- The money is not final when the user clicks pay. Create the print order from the browser, or before the webhook, and you will ship physical product for payments that never complete.
The fix
A server-side pipeline, with the order created only after Stripe confirms the payment.
First, rasterise the SVG at 4x so the print is sharp, and upload that PNG to a Supabase Storage bucket (print-queue, public read) so Gelato can fetch a stable URL:
const png = await rasterize(collageSvg, { scale: 4 }); // print resolution
await admin.storage.from('print-queue').upload(path, png, { contentType: 'image/png' });
Then a gelato-checkout Edge Function creates a Stripe Checkout session carrying the metadata the webhook will need, including a flag so one webhook can tell print orders apart from subscriptions:
// supabase/functions/gelato-checkout/index.ts
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: [{ quantity: 1, price_data: {
currency: 'eur', unit_amount: 1499,
product_data: { name: 'A3 poster' },
}}],
metadata: {
print_order: 'true',
product_uid: 'posters_pf_a3_pt_170-gsm-coated-silk_cl_4-0_hor',
image_path: storedPath,
},
success_url, cancel_url,
});
Finally the Stripe webhook (the same function that handles subscriptions, branching on metadata.print_order === 'true') fires on checkout.session.completed, and ONLY THEN creates the Gelato order and records it:
// inside stripe-webhook, after verifying the event signature
await fetch('https://order.gelatoapis.com/v4/orders', {
method: 'POST',
headers: { 'X-API-KEY': GELATO_API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({
orderType: 'order',
items: [{
itemReferenceId: session.id,
productUid: meta.product_uid, // exact, long, see below
fileUrl: publicImageUrl,
quantity: 1,
}],
shipmentMethodUid: 'normal',
shipTo: {
countryIsoCode: 'NL', // NOT `country`
firstName, lastName, addressLine1, city, postCode,
},
}),
});
await admin.from('print_orders').insert({ stripe_session_id: session.id, status: 'created' });
The two gotchas that cost the most time:
- Gelato’s shipping country field is
shipTo.countryIsoCode, notcountry. Sendcountryand the order is silently rejected. - The product UID is a long exact string like
posters_pf_a3_pt_170-gsm-coated-silk_cl_4-0_hororcanvas_12x12-inch-300x300-mm_canvas_wood-fsc-slim_4-0_hor. One character off and there is no such product.
And the rule that keeps it correct and safe: the Gelato order goes out from the webhook, after Stripe confirms the payment, never from the client. The Gelato API key and the Stripe secret stay in the Edge Function and Supabase secrets, never the frontend. (For reference, the sell prices were EUR 14.99 for the A3 poster and EUR 34.99 for the 30x30 cm canvas.)
The lesson
Print-on-demand is a render then host then pay then fulfil pipeline, and the order ships from the payment webhook, not the browser. Rasterise at print resolution, host where the printer can reach it, and trust only the webhook that the money actually cleared.