Stripe

Gelato API with Next.js: send print-on-demand orders after Stripe Checkout

We earn commissions when you shop through the links below.

Connect a Next.js store to the Gelato API: quote shipping, create the order from a Stripe webhook without duplicates, test with draft orders, and track status with Gelato webhooks.

12 min read

Disclosure: the “Create a Gelato account” links in this article are affiliate links. If you sign up through one, Designyff may earn a commission. The Gelato documentation links and every other link are ordinary links. Nothing here was paid for by Gelato.

Shopify and Etsy stores connect to Gelato with an app. A store you built yourself in Next.js has no app to install. It talks to the Gelato API directly: your checkout takes the money, your server sends the order to Gelato, and Gelato prints and ships it from a production partner close to the customer.

That is a small amount of code, but three places go wrong in practice: a Stripe webhook that creates the same print order twice, an address that Gelato rejects after the customer has already paid, and a single order that Gelato splits into several. This guide covers all three with an App Router route handler, Stripe Checkout, and Prisma.

If you do not have an account yet, create a Gelato account and generate an API key in the API Portal of the Gelato dashboard. Everything below uses the endpoints in the Gelato API documentation as of 12 October 2026.

How the pieces fit

  1. The customer picks a product. Your database maps it to a Gelato productUid and the URL of a print file.
  2. Before checkout, your server asks Gelato for a quote, so the shipping price and delivery estimate on your page are real.
  3. Stripe Checkout collects payment and the shipping address.
  4. The checkout.session.completed webhook creates the Gelato order, exactly once.
  5. Gelato calls your webhook as the order is printed and shipped. You store the tracking link and show it to the customer.

Gelato does not take a commission on your sales. You pay Gelato for the product and shipping of each order and set your own retail price (Gelato pricing). Your margin is the Stripe amount minus the Gelato receipt minus Stripe fees.

A small Gelato client

Every request goes over HTTPS with the key in an X-API-KEY header. Orders live on https://order.gelatoapis.com and the product catalog on https://product.gelatoapis.com. The key can create orders that cost money, so it stays on the server: no NEXT_PUBLIC_ prefix, and the file imports server-only.

// src/lib/gelato.ts
import "server-only";

const ORDER_API = "https://order.gelatoapis.com/v4";

export class GelatoError extends Error {
  constructor(public status: number, public body: string) {
    super(`Gelato API ${status}: ${body.slice(0, 300)}`);
  }
}

export async function gelato<T>(path: string, init: RequestInit = {}): Promise<T> {
  const key = process.env.GELATO_API_KEY;
  if (!key) throw new Error("GELATO_API_KEY is not set");

  const res = await fetch(`${ORDER_API}${path}`, {
    ...init,
    headers: {
      "Content-Type": "application/json",
      "X-API-KEY": key,
      ...init.headers
    },
    cache: "no-store"
  });

  if (!res.ok) throw new GelatoError(res.status, await res.text());
  return res.json() as Promise<T>;
}

Gelato answers 429 when you send too many requests and recommends exponential backoff. A 4xx other than 429 is a problem with your data (a missing field, an address it cannot ship to). Do not retry those blindly. Log the body and fix the input.

Find the productUid

A Gelato product is identified by a long productUid such as apparel_product_gca_t-shirt_gsc_crewneck_gcu_unisex_gqa_classic_gsi_s_gco_white_gpr_4-4. Size, colour and paper are part of the string, so every variant you sell is its own UID.

You can copy UIDs from the dashboard, or search a catalog with the product search endpoint:

curl -X POST "https://product.gelatoapis.com/v3/catalogs/posters/products:search" \
  -H "X-API-KEY: $GELATO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "attributeFilters": { "Orientation": ["ver"] }, "limit": 50 }'

Store the UID on your own variant row. Do not build it from strings at request time. A typo produces a UID that does not exist, and you only find out when a paid order fails.

model ProductVariant {
  id            String  @id @default(cuid())
  title         String
  priceCents    Int
  gelatoUid     String
  printFileUrl  String
}

model Order {
  id                String   @id @default(cuid())
  variantId         String
  quantity          Int      @default(1)
  status            String   @default("pending") // pending | paid | submitting | submitted | failed
  stripeSessionId   String?  @unique
  gelatoOrderIds    String[] @default([])
  trackingUrl       String?
  createdAt         DateTime @default(now())
}

String[] needs PostgreSQL. On another database, store the Gelato ids in a separate table. The print file URL must be one Gelato can download. It accepts PDF, PNG, TIFF, SVG and JPEG, and for PDF it asks for a PDF/X standard such as PDF/X-1a:2003 or PDF/X-4.

Quote shipping before checkout

POST /v4/orders:quote returns the product price, the available shipping methods with prices, and a delivery window for a recipient address (quote docs). Use it when the customer has entered a country, so the shipping line in your cart is not a guess.

type Quote = {
  quotes: {
    fulfillmentCountry: string;
    shipmentMethods: {
      name: string;
      shipmentMethodUid: string;
      price: number;
      currency: string;
      minDeliveryDays: number;
      maxDeliveryDays: number;
    }[];
    products: { itemReferenceId: string; price: number; currency: string }[];
  }[];
};

export async function quote(order: { id: string; quantity: number }, variant: { gelatoUid: string; printFileUrl: string }, recipient: Recipient) {
  return gelato<Quote>("/orders:quote", {
    method: "POST",
    body: JSON.stringify({
      orderReferenceId: order.id,
      customerReferenceId: recipient.email,
      currency: "EUR",
      allowMultipleQuotes: false,
      recipient,
      products: [{
        itemReferenceId: `${order.id}-1`,
        productUid: variant.gelatoUid,
        files: [{ type: "default", url: variant.printFileUrl }],
        quantity: order.quantity
      }]
    })
  });
}

The quote endpoint requires a full recipient (name, address line, city, post code, email), not only a country. If you want a shipping estimate on a product page before the visitor has typed an address, quote once per target country with a representative address on the server and cache the result. Then quote again with the real address before you charge.

Create the order from the Stripe webhook

Create the Checkout Session with your own order id in metadata, the shipping countries you sell to, and mode: "payment". The setup is the same as a one-time payment in Stripe Checkout with Next.js:

await stripe().checkout.sessions.create({
  mode: "payment",
  line_items: [{
    quantity: order.quantity,
    price_data: {
      currency: "eur",
      unit_amount: variant.priceCents,
      product_data: { name: variant.title }
    }
  }],
  shipping_address_collection: { allowed_countries: ["SI", "AT", "DE", "IT", "HR"] },
  success_url: `${process.env.APP_URL}/orders/${order.id}?paid=1`,
  cancel_url: `${process.env.APP_URL}/cart`,
  metadata: { orderId: order.id }
});

Then the webhook. Verify the Stripe signature first, as described in Stripe webhooks with Next.js. The important part is the claim step: Stripe can deliver the same event more than once, and the Gelato create endpoint documents no idempotency key. Two deliveries must not become two printed parcels.

// src/app/api/stripe/webhook/route.ts (excerpt, after constructEvent)
if (event.type === "checkout.session.completed") {
  const session = event.data.object;
  const orderId = session.metadata?.orderId;
  if (!orderId || session.payment_status !== "paid") {
    return NextResponse.json({ received: true });
  }

  // Claim the order. Only one delivery can move it from pending/paid to submitting.
  const claimed = await prisma.order.updateMany({
    where: { id: orderId, status: { in: ["pending", "paid"] } },
    data: { status: "submitting", stripeSessionId: session.id }
  });
  if (claimed.count === 0) return NextResponse.json({ received: true });

  try {
    await submitToGelato(orderId, session);
  } catch (err) {
    await prisma.order.update({ where: { id: orderId }, data: { status: "failed" } });
    console.error("Gelato order failed", orderId, err);
    // Answer 200 anyway: Stripe retrying cannot fix bad address data.
  }
}

The claim is a single UPDATE ... WHERE status IN (...), so two concurrent deliveries cannot both get count: 1. A failed order is left for a person to look at: the customer has paid, so either fix the data and resubmit, or refund in Stripe. Add an alert there, not a silent log line.

payment_status matters for payment methods that settle later. For those, Stripe sends checkout.session.async_payment_succeeded when the money arrives. Handle that event with the same claim and submit code.

Map the Stripe address to Gelato

Gelato wants firstName and lastName separately, limits names to 25 characters and address lines to 35, and requires state for the US, Canada and Australia (create order docs). Stripe gives you one name and a free-text address. Map it explicitly:

import type Stripe from "stripe";

type Recipient = {
  firstName: string; lastName: string;
  addressLine1: string; addressLine2?: string;
  city: string; postCode: string; state?: string;
  country: string; email: string; phone?: string;
};

function toRecipient(session: Stripe.Checkout.Session): Recipient {
  // Newer Stripe API versions put the address under collected_information.
  const shipping =
    (session as any).collected_information?.shipping_details ??
    (session as any).shipping_details;
  if (!shipping?.address) throw new Error("No shipping address on session");

  const a = shipping.address;
  const name = String(shipping.name || "").trim();
  const space = name.lastIndexOf(" ");
  const firstName = space > 0 ? name.slice(0, space) : name;
  const lastName = space > 0 ? name.slice(space + 1) : name;

  return {
    firstName: firstName.slice(0, 25),
    lastName: lastName.slice(0, 25),
    addressLine1: String(a.line1 || "").slice(0, 35),
    addressLine2: a.line2 ? String(a.line2).slice(0, 35) : undefined,
    city: String(a.city || "").slice(0, 30),
    postCode: String(a.postal_code || "").slice(0, 15),
    state: a.state || undefined,
    country: String(a.country || ""),
    email: session.customer_details?.email || "",
    phone: session.customer_details?.phone || undefined
  };
}

Truncating an address line can lose information. If an order would be truncated, mark it failed and check it by hand rather than shipping to half an address. The real fix is in your own form or in the countries you allow.

Send the order

type GelatoOrder = { id: string; fulfillmentStatus: string; connectedOrderIds?: string[] };

async function submitToGelato(orderId: string, session: Stripe.Checkout.Session) {
  const order = await prisma.order.findUniqueOrThrow({
    where: { id: orderId },
    include: { variant: true }
  });

  const created = await gelato<GelatoOrder>("/orders", {
    method: "POST",
    body: JSON.stringify({
      orderType: process.env.GELATO_ORDER_TYPE === "order" ? "order" : "draft",
      orderReferenceId: order.id,
      customerReferenceId: session.customer_details?.email || order.id,
      currency: "EUR",
      items: [{
        itemReferenceId: `${order.id}-1`,
        productUid: order.variant.gelatoUid,
        files: [{ type: "default", url: order.variant.printFileUrl }],
        quantity: order.quantity
      }],
      shipmentMethodUid: "standard",
      shippingAddress: toRecipient(session),
      metadata: [{ key: "stripeSessionId", value: session.id }]
    })
  });

  const ids = created.connectedOrderIds?.length ? created.connectedOrderIds : [created.id];
  await prisma.order.update({
    where: { id: order.id },
    data: { status: "submitted", gelatoOrderIds: ids }
  });
}

The excerpt assumes a variant relation on Order. shipmentMethodUid accepts normal, standard, express, or a specific UID from the quote. Leave it out and Gelato picks the cheapest method. Use the method the customer paid for at checkout.

Test with draft orders

orderType: "draft" creates an order that does not go into production until you convert it in the dashboard or with the Order Patch API. The code above defaults to drafts and only sends real orders when GELATO_ORDER_TYPE=order is set. Keep that variable unset in preview deployments, so a test checkout with a Stripe test card never prints anything.

Run one real order to yourself before launch. A draft proves the request is valid. It does not show you the print quality, the packaging, or the delivery time.

Split orders

Gelato produces in many countries and hubs. If the items in one order cannot be made in the same place, Gelato splits it into connected orders. Each one has its own Gelato id and the same orderReferenceId, and the create response lists all of them in connectedOrderIds (how orders work).

That is why the schema stores an array of Gelato ids. Webhooks arrive per connected order, and the customer may get two parcels with two tracking links. Key everything on your orderReferenceId and show every tracking link you receive.

Track fulfillment with Gelato webhooks

Configure a webhook URL in the Gelato API Portal. Gelato posts JSON to it and retries 3 times, 5 seconds apart, if you do not return a 2xx (webhook docs). The events that matter for a store are:

EventUse it for
order_status_updatedOrder-level status (passed, in_production, printed, shipped, delivered, failed, on_hold…)
order_item_status_updatedItem-level status, with a comment when something went wrong
order_item_tracking_code_updatedTracking code and URL once an item ships

The webhook documentation does not describe a signature header like Stripe’s. Treat the payload as a notification, not as proof. Register a URL that contains a long random token, check that token, and read the order back from the API before you change anything:

// src/app/api/gelato/webhook/[token]/route.ts
import { NextResponse } from "next/server";
import { timingSafeEqual } from "node:crypto";
import { gelato } from "@/lib/gelato";
import { prisma } from "@/lib/prisma";

function sameToken(a: string, b: string) {
  const x = Buffer.from(a);
  const y = Buffer.from(b);
  return x.length === y.length && timingSafeEqual(x, y);
}

export async function POST(request: Request, { params }: { params: Promise<{ token: string }> }) {
  const { token } = await params;
  if (!sameToken(token, process.env.GELATO_WEBHOOK_TOKEN || "")) {
    return new NextResponse(null, { status: 404 });
  }

  const event = await request.json();
  if (!event?.orderId || !event?.orderReferenceId) return NextResponse.json({ ok: true });

  // Confirm with Gelato instead of trusting the posted body.
  const order = await gelato<{
    id: string;
    orderReferenceId: string;
    fulfillmentStatus: string;
    shipment?: { packages?: { trackingUrl?: string }[] };
  }>(`/orders/${encodeURIComponent(event.orderId)}`);

  const trackingUrl = order.shipment?.packages?.find((p) => p.trackingUrl)?.trackingUrl;
  await prisma.order.updateMany({
    where: { id: order.orderReferenceId, gelatoOrderIds: { has: order.id } },
    data: {
      ...(trackingUrl ? { trackingUrl } : {}),
      ...(order.fulfillmentStatus === "failed" ? { status: "failed" } : {})
    }
  });

  return NextResponse.json({ ok: true });
}

params is a Promise in Next.js 15 route handlers, so it is awaited. The where clause requires the Gelato id to be one you stored for that order, so a forged body with a real-looking id for someone else’s order changes nothing. With split orders, keep one tracking URL per Gelato order id instead of the single column used here.

Answer quickly. Gelato gives up after three attempts, so send the customer email from a queue or a background job, not inside the handler.

Launch checklist

  • GELATO_API_KEY and GELATO_WEBHOOK_TOKEN are server-only environment variables.
  • Every variant you sell has a stored productUid and a print file URL Gelato can download.
  • The cart shows a real quote for the customer’s address before you charge.
  • checkout.session.completed claims the order with one conditional update before calling Gelato.
  • Failed Gelato submissions alert a person, and you know whether you will fix and resubmit or refund.
  • Preview deployments create drafts. Production sets GELATO_ORDER_TYPE=order.
  • The Gelato webhook URL contains a secret token and the handler re-reads the order from the API.
  • You placed one real order to yourself and checked the product.

Create a Gelato account and get an API key

FAQ

Do I need Shopify to use Gelato?

No. Shopify, Etsy and other platforms connect through Gelato’s integrations, but the Order API works with any backend. A custom Next.js store calls it directly, as shown above.

Does Gelato charge a monthly fee for the API?

Gelato’s pricing page says there are no upfront fees and no commission: you pay for the products and shipping you order. Gelato also sells optional subscriptions with product and shipping discounts. Check gelato.com/pricing for the current terms.

Can I test the Gelato API without printing anything?

Yes. Create orders with orderType: "draft". Drafts do not go into production until you convert them. Use the quote endpoint to test prices and shipping without creating an order at all.

What happens if Gelato rejects an order after the customer paid?

The order gets status failed, for example because of an invalid address or an item that is out of stock. Your webhook should mark it for review. You then correct and resubmit it, or refund the payment in Stripe.

No. gelato.com works without it. The link is how Designyff gets credited if you do sign up through it.

Product updates for developers shipping with Designyff kits.

Guides: Next.js, Stripe, and SaaS starter kits Professional website design for small businesses

Purchased source may be used in personal, commercial, and client projects. Do not redistribute or resell the original source. Terms of Service Privacy Policy

Designyff © 2026 | Starter kits for developers