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 fulfilling anything by hand. “Add a print button” sounds like one API call. It turned out to be 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.
What was happening
Print-on-demand is render, then host, then pay, then fulfil, and three of those four steps fail in ways that are not obvious up front. The artwork is an SVG on screen, and sent straight to print it comes out soft, because a printer needs a high-resolution raster rather than a display-sized vector. Gelato fetches the image by URL, so it cannot reach a blob in your app’s memory or a signed URL that expires in 60 seconds. And 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, checks that payment_status is paid, and only then creates the Gelato order and records it. Delayed methods such as SEPA complete while still unpaid and arrive again as checkout.session.async_payment_succeeded once the money clears, so the webhook listens for both:
// 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',
orderReferenceId: session.id,
customerReferenceId: userId,
currency: 'EUR',
items: [{
itemReferenceId: 'item_1',
productUid: meta.product_uid, // exact, long, see below
files: [{ type: 'default', url: publicImageUrl }],
quantity: 1,
}],
shipmentMethodUid: 'normal',
shippingAddress: {
country: 'NL', // ISO 3166 code
firstName, lastName, addressLine1, city, postCode, email,
},
}),
});
await admin.from('print_orders').insert({ stripe_session_id: session.id, status: 'created' });
Two details cost the most time. The v4 order API wants the address in shippingAddress, with the two-letter ISO code in country (Gelato’s create-order reference). And the product UID is a long exact string like posters_pf_a3_pt_170-gsm-coated-silk_cl_4-0_hor or canvas_12x12-inch-300x300-mm_canvas_wood-fsc-slim_4-0_hor, where one character off means there is no such product.
The rule that keeps it correct and safe is that 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, host, pay, fulfil pipeline, and the order ships from the payment webhook. Rasterise at print resolution, host where the printer can reach it, and trust only the webhook that the money cleared.
Correction, 2026-09-23: an earlier version of this post gave the address field as shipTo.countryIsoCode and said sending country gets the order rejected. That was wrong for the v4 API, which takes shippingAddress.country. The code above is corrected.
Discussion
Powered by GitHub. Sign in to leave a comment.