Recipe to shopping list API: turn any recipe into a grocery cart
By Chris Artmann ·
Every recipe app ends up in the same place. Someone has picked three dinners for the week. Now they want to know what to buy.
On the roadmap it looks like the easiest feature there is. Loop over the ingredients, print them, done. Then you build it and get a list with garlic on it four times, olive oil written in teaspoons, and a line telling you to buy salt. Nobody wants that list. They'll write their own on the back of an envelope.
This post covers why a good list is harder than it looks, how much of it an API can take off your plate, and how to get from a list to an actual grocery cart. At the end we do the same thing as a tool call, so an AI assistant can do it in the middle of a chat.
Why a shopping list is harder than it looks
There are four hard parts. Each one looks small on its own.
Merging. Two recipes both want garlic. One says "2 cloves garlic, minced". The other says "2 garlic cloves". A third writes "Garlic" with a capital G and no amount at all. If you group by the raw text, you get three lines. You need to know they're all the same ingredient, which means mapping every spelling to a canonical id. "Scallion", "green onion" and "spring onion" should land in one place too.
Even with ids, some things shouldn't be merged. "2 cloves garlic" and "1 tsp minced garlic" are the same plant. You still can't add them without guessing how many teaspoons are in a clove. So we don't. They stay two lines. Two honest lines beat one confident wrong number.
Units. Once you know two lines are the same ingredient, you have to add them up. That only works when the units agree. Grams add to grams and cups add to cups. Cups and grams don't add at all unless you know the density of the thing in the cup, and that's a whole other post: cups to grams.
Scaling makes it worse. Someone asks for a 2-serving recipe at 4 servings and you double everything. Now a recipe that called for 2 tsp of olive oil calls for 4. Add another recipe's 4 and you get "8 tsp olive oil", which is correct and useless. Nobody measures out 8 teaspoons. Amounts should be rounded to something a cook would write, and small decimal cups should become spoons. Scaling recipes goes deeper on that.
Staples. Almost every savory recipe wants salt and pepper. Most want oil. Putting them on every list is noise. The person already owns them. But "most people" isn't "everyone", and the student who just moved out really does need to buy salt. So staples shouldn't vanish. They should move to a small "check you have" section.
Pack sizes. The recipe needs 230 g of pasta. The store sells 500 g bags. The recipe needs half a cup of scallions. The store sells them by the bunch. A list that says "½ cup scallion" is accurate. It's just not what you hand to a store. Somewhere between the recipe and the cart, amounts have to become things you can pick off a shelf.
Here's what the Tiny Plates /shopping-list endpoint gives back for two real recipes, each scaled to 4 servings: Weeknight Skillet Sazon Chicken Tenders and Creamy Garlic Roasted Red Pepper Pasta.
curl "https://api.tinyplates.dev/shopping-list?recipes=69e22168d0295ac8a90118bc:4,69e21bd0d0295ac8a9011521:4" \
-H "Authorization: Bearer $TINY_PLATES_KEY"
Trimmed to a few lines, the answer looks like this:
{
"items": [
{ "category": "produce", "display": "4 cloves garlic", "name": "garlic",
"recipeIds": ["69e22168d0295ac8a90118bc", "69e21bd0d0295ac8a9011521"] },
{ "category": "pantry", "display": "8 tsp olive oil", "name": "olive oil",
"recipeIds": ["69e22168d0295ac8a90118bc", "69e21bd0d0295ac8a9011521"] },
{ "category": "produce", "display": "½ cup scallion", "name": "scallion",
"recipeIds": ["69e21bd0d0295ac8a9011521"] },
{ "category": "spices", "display": "1 tsp salt, plus more", "name": "salt",
"plusMore": true, "recipeIds": ["69e22168d0295ac8a90118bc"] }
],
"recipes": [
{ "id": "69e22168d0295ac8a90118bc", "servings": 4, "title": "Weeknight Skillet Sazon Chicken Tenders" },
{ "id": "69e21bd0d0295ac8a9011521", "servings": 4, "title": "Creamy Garlic Roasted Red Pepper Pasta" }
]
}
The garlic and olive oil from both recipes are merged into one line each. recipeIds tells you where every line came from, so you can let people tap a line and see which dinner needs it. Every line has an aisle. "Oil for frying" style lines that come without an amount get folded into the measured line as plusMore. If one recipe says "1 cup" and another adds "3 tbsp" in a unit the first can't take, the extra amount rides along in extras instead of being dropped. And display is written for a person, so print that instead of rebuilding text yourself. The week-of-recipes post walks through these fields one by one.
So merging and units are the API's job. It also scales and sorts by aisle. Look at that olive oil line again, though. "8 tsp" is the right sum and the wrong thing to shop for. Staples and pack sizes are about the person and the store, not about the recipe. Those two stay in your code, and that's fine. They're short.
Building it, from recipes to cart
Here's the whole thing in plain JavaScript. Pick some recipes, ask the API for a list, pull the staples aside, turn amounts into packs, and group by aisle. If you already have recipe ids from a planner like the one in the meal plan API post, you can plug them straight in.
const API = 'https://api.tinyplates.dev'
async function getShoppingList(picks) {
const recipes = picks
.map(({ id, servings }) => (servings ? `${id}:${servings}` : id))
.join(',')
const res = await fetch(`${API}/shopping-list?recipes=${recipes}`, {
headers: { Authorization: `Bearer ${process.env.TINY_PLATES_KEY}` }
})
if (!res.ok) throw new Error(`Shopping list failed: ${res.status}`)
return res.json()
}
You can send up to 20 recipes in one call. One thing to know: if you ask for a serving count on a recipe that doesn't say how many it serves, the whole request fails with an error. It won't guess. Handle that by retrying without the servings, or by telling the user.
Next, staples. Keep a short list of what most kitchens have and move those lines aside instead of deleting them.
const STAPLES = new Set([
'salt', 'kosher salt', 'sea salt', 'pepper', 'black pepper',
'oil', 'olive oil', 'vegetable oil', 'sugar', 'flour',
'all-purpose flour', 'water'
])
const isStaple = (item) => STAPLES.has(item.name.toLowerCase())
Then pack sizes. There's no universal table for this. It depends on your country and your store. Start with a small map for the things that obviously come in packs and show the recipe amount next to it, so nobody has to trust your rounding blindly.
const PACKS = {
scallion: '1 bunch',
garlic: '1 head',
'chicken broth': '1 carton',
tahini: '1 jar'
}
const toBuy = (item) =>
PACKS[item.name]
? `${item.name}: ${PACKS[item.name]} (recipes need ${item.display})`
: item.display
Last, group by aisle, in the order you walk through a store.
const AISLES = ['produce', 'meat', 'seafood', 'dairy', 'bakery',
'frozen', 'pantry', 'spices', 'drinks', 'other']
function buildList(items) {
const aisles = new Map(AISLES.map((aisle) => [aisle, []]))
const checkYouHave = []
for (const item of items) {
if (isStaple(item)) {
checkYouHave.push(item.display)
continue
}
const aisle = aisles.get(item.category) ?? aisles.get('other')
aisle.push(toBuy(item))
}
return {
aisles: [...aisles].filter(([, lines]) => lines.length > 0),
checkYouHave
}
}
const { items } = await getShoppingList([
{ id: '69e22168d0295ac8a90118bc', servings: 4 },
{ id: '69e21bd0d0295ac8a9011521', servings: 4 }
])
console.log(buildList(items))
For our two dinners, produce now reads "garlic: 1 head (recipes need 4 cloves garlic)" and "scallion: 1 bunch (recipes need ½ cup scallion)". The olive oil and salt moved to "check you have". That's a list you could actually shop from.
Want to see it running before you write any code? Try the shopping list demo. It does the same thing with recipes you pick.
Now the cart. A printed list is nice. A filled cart is better, and a few grocery platforms let you hand one over.
The easiest one to start with is the Instacart Developer Platform. You send it a list of line items. It sends back a link to a hosted page. The user opens it, picks a store near them, checks what got matched, and adds it to their cart. You never deal with store inventory or checkout yourself.
async function instacartLink(items, title) {
const res = await fetch(
'https://connect.instacart.com/idp/v1/products/products_link',
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.INSTACART_KEY}`,
'Content-Type': 'application/json',
Accept: 'application/json'
},
body: JSON.stringify({
title,
link_type: 'shopping_list',
line_items: items.map((item) => ({
name: item.name,
display_text: item.display
}))
})
}
)
const { products_link_url } = await res.json()
return products_link_url
}
A few notes from their docs. You can add line_item_measurements with a quantity and unit to help matching, but only in units they support, so map ours first. Links can be cached, and they ask you to reuse one rather than create a new link for the same list every time. There's a development host at connect.dev.instacart.tools to test against before you go live. The Instacart docs have the full list of fields.
Kroger works differently. Its public API can add items straight into a signed-in customer's cart. That means an OAuth sign-in with the customer first. Then you look up each product in their product search to get a UPC, and only then add it with a quantity. It's more work, and only for one chain's stores. In return the items land in their real cart.
And don't skip the boring option. A share button that sends plain text to Notes or a messaging app covers a lot of people. So does copy to clipboard. Plenty of households shop from a list in a group chat, and that's fine.
Last, the agent version. If you're building an assistant, you don't need any of the glue above for the first step. Tiny Plates has an MCP server, and one of its tools is build_shopping_list. It calls the same endpoint. The model passes recipe ids from an earlier search, with servings if it wants them:
{
"name": "build_shopping_list",
"arguments": {
"recipes": ["69e22168d0295ac8a90118bc:4", "69e21bd0d0295ac8a9011521:4"]
}
}
It gets back the same merged, aisle-grouped items. So a chat can go "find me two quick dinners for four", then "ok, what do I need to buy?" and get a real answer, not a list the model added up in its head. Models are bad at adding 2 tsp and 1 tbsp. Let the tool do the math and let the model do the talking: staples to skip, which store, whether to make an Instacart link. If MCP is new to you, start with what MCP is.
That's the whole pipeline. Recipes go in, the API handles merging, units, scaling and aisles, and your code handles the parts that depend on the person and the store. The list ends up in a cart, a chat or a text message, wherever your users already shop.