ARTICLES

AI Product Categorization

Using Open AI to categorize individual products in a transaction

Most transaction categorization relies on the Merchant Category Code (MCC) provided by the card network (Visa or Mastercard). However, MCC data is often too broad. Retailers like Target, Costco, and Amazon sell a wide variety of products, so the resulting category may simply be "General Store" or "Shopping", which is not particularly useful.

With Klutch, we can improve this. By accessing product-level line items (for example, via the Receipts mini-app) and sending those product descriptions to an AI model, we can assign much more accurate categories to the transaction items.

Strategy Overview

  1. Subscribe to Klutch’s transaction webhook.
  2. Check if the transaction contains product-level items.
  3. Retrieve product descriptions.
  4. Send the product descriptions to an AI model to determine the most appropriate category.
  5. Save the resulting category back to the transaction record.

Project Setup

Prerequisites

This guide assumes:

  • You have a Klutch account with API access enabled.
  • You are familiar with Node.js, GraphQL and JSON.
Authenticating with Klutch's API

We will start with a minimal Node.js setup to obtain a session token using createSessionToken.

import dotenv from 'dotenv';

dotenv.config();

const ENDPOINT = process.env.KLUTCH_ENDPOINT;
const CLIENT_ID = process.env.KLUTCH_CLIENT_ID;
const SECRET_KEY = process.env.KLUTCH_SECRET_KEY;

async function gql(query, variables, token) {
  const res = await fetch(ENDPOINT, {
    method: "POST",
    headers: {
      "content-type": "application/json",
      ...(token ? { Authorization: `Bearer ${token}` } : {})
    },
    body: JSON.stringify({ query, variables })
  });
  const json = await res.json();
  if (json.errors) throw new Error(JSON.stringify(json.errors));
  return json.data;
}

async function main() {
  const token = await getToken()  
}

async function getToken() {
    const { createSessionToken } = await gql(
    `mutation($clientId:String,$secretKey:String){
       createSessionToken(clientId:$clientId, secretKey:$secretKey)
     }`,
    { clientId: CLIENT_ID, secretKey: SECRET_KEY }
  );
  const token = createSessionToken;
  return token
}

Receiving Webhooks

Enable webhooks under: My Account → Developers. Make sure you select "Transaction Items" to receive updates when a transaction item is added to a transaction

We will use Express to receive the webhook callback

import express from "express";

const app = express();

app.use(
  express.raw({ type: "application/json" })
);

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Listening on port ${PORT}`);
});

A transaction item event looks like this:

{
    "principal": {
        "_alloyCardType": "com.alloycard.core.entities.user.User",
        "entityID": "<<USERID>>"
    },
    "event": {
        "_alloyCardType": "com.alloycard.core.entities.transaction.TransactionItemCreatedEvent",
        "transactionItem": {
            "_alloyCardType": "com.alloycard.core.entities.transaction.TransactionItem",
            "entityID": "txi_ed0723aedc374d5dbadef4a229cd327d"
        },
  "transaction": {
            "_alloyCardType": "com.alloycard.core.entities.transaction.Transaction",
            "entityID": "txn_ed07bcaedc374d5dbadef4a229cd327d"
        },
        "createdAt": 1636006199,
        "eventId": "ad2e1f37-2f36-4978-bd85-2220776064bb"
    },
    "webhookUrl": ""
}

Let’s create code that reads the event webhook, make sure that it is a “TransactionItemCreatedEvent” and then fetch the transaction from Klutch’s server.

app.post("/webhook", async (req, res) => {
  try {
    const payload = JSON.parse(req.body.toString("utf-8"));
   
    // Check if this is a transaction created event
    if (payload.event?._alloyCardType !== "com.alloycard.core.entities.transaction.TransactionItemCreatedEvent") {
      console.log("Ignoring non-transaction event:", payload.event?._alloyCardType);
      return res.status(200).send("ok");
    }

    const transactionId = payload.event.transaction.entityID;

    const token = await getToken();
    const {transaction, items} = await handleTransaction(transactionId, token);
   
    await saveItemCategories(transactionId, items, token);
 
    res.status(200).send("ok");

  } catch (err) {
    console.error("Error handling webhook:", err);
    res.status(500).send("Server error");
  }
});

We also need to implement our handleTransaction function. We will fetch all products from our Transaction and send them to our AI to categorize it for us. This function first will get a list of all available categories (we can create categories using Klutch App or via the API), then get the product Items and finally get a category using AI

async function handleTransaction(transactionId, token) {

  // Fetch available categories
  const availableCategories = await getAvailableCategories(token);
  const categoryNames = availableCategories.map(cat => cat.name);
  const categoryNameToId = Object.fromEntries(availableCategories.map(cat => [cat.name, cat.id]));

  // Fetch transaction details
  const { transaction } = await gql(`
    query($id: String!) {
      transaction(id: $id) {
        id
        amount
        transactionStatus
        declineReason
        cardPresent  
        items {
          id
          category {
            id
            name
          }
          description
          price
          quantity
        }
      }
    }
  `, { id: transactionId }, token);
 
  // Get categories from AI for each item
  const items = transaction.items || [];
  const categorizedItems = await Promise.all(
    items.map(async (item) => {
      const aiCategoryName = await getAICategory(item.description, categoryNames);
      return {
        ...item,
        aiCategory: {
          name: aiCategoryName,
          id: categoryNameToId[aiCategoryName] || null
        }
      };
    })
  );

Getting all categories

The function below uses our GraphQL server to fetch all available categories. We can also create new categories using the API or the Klutch App.

async function getAvailableCategories(token) {
  try {
    const { transactionCategories } = await gql(`
      query {
        transactionCategories {
          id
          name
        }
      }
    `, {}, token);

    return transactionCategories;
  } catch (error) {
    console.error("Error fetching categories:", error);
    return [];
  }
}

Using OpenAI to categorize our products

We then finally call the OpenAI API (make sure you have an OpenAI key before calling) to retrieve the correct category for the product:

async function getAICategory(description, categoryOptions = []) {
  try {
    const categoryList = categoryOptions.length > 0
      ? `\n\nAvailable categories: ${categoryOptions.join(", ")}`
      : "";

    const response = await fetch("https://api.openai.com/v1/chat/completions", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "Authorization": `Bearer ${process.env.OPENAI_API_KEY}`
      },
      body: JSON.stringify({
        model: "gpt-3.5-turbo",
        messages: [
          {
            role: "system",
            content: `You are a product categorization assistant. Respond with ONLY a single category name from the provided options for the given product description.${categoryList}`
          },
          {
            role: "user",
            content: `Categorize this product: ${description}`
          }
        ],
        temperature: 0.3,
        max_tokens: 10
      })
    });

    const data = await response.json();
    if (data.error) throw new Error(data.error.message);
   
    return data.choices[0].message.content.trim();
  } catch (error) {
    console.error("Error getting AI category:", error);
    return null;
  }
}

Saving the new category

The last thing we need to do is saving the new category for that product, we can do that using the change() API from TransactionItem:

async function saveItemCategories(transactionId, items, token) {
  try {
    for (const item of items) {
      if (item.aiCategory?.id) {
        await gql(`
          mutation($transactionId: String!, $itemId: String!, $categoryId: String!) {
            transaction(id: $transactionId) {
              item(id: $itemId) {
                change(categoryId: $categoryId) {
                  category {
                    id
                  }
                }
              }
            }
          }
        `,
        {
          transactionId: transactionId,
          itemId: item.id,
          categoryId: item.aiCategory.id
        },
        token);
       
        console.log(`Updated item ${item.id} with category ${item.aiCategory.name} (${item.aiCategory.id})`);
      } else {
        console.warn(`Skipping item ${item.id} - no valid AI category assigned`);
      }
    }
  } catch (error) {
    console.error("Error saving item categories:", error);
    throw error;
  }
}

Conclusion and Next Step

This concludes this tutorial, but this is just a small sample of what can be accomplished with Klutch.

As a next step, think about enhancing this code by creating a category if it doesn’t exist or by feeding ChatGPT with more transaction Data such as merchant name and address.

You can check out the entire source code for this tutorial on Github

About klutch

We’re on a mission to make your life better, one breakthrough app at a time.

Klutch grew from our belief that people should demand more from technology. So when we noticed a critical need for more control and flexibility in how people make payments, we went to work pioneering a framework that delivered just that.

ABOUT KLUTCH
Renato steinberg - ceo
2:09
A portrait of Renato Steinberg