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.
This guide assumes:
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
}
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
}
};
})
);
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 [];
}
}
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;
}
}
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;
}
}
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
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.
