Stripe + Zoho CRM: How to Reconcile Payments and Customer Data
The step-by-step guide to syncing Stripe payments directly into Zoho CRM without losing data.
Stripe + Zoho CRM: How to Reconcile Payments and Customer Data
The step-by-step guide to syncing Stripe payments directly into Zoho CRM without losing data.
Zoho Intergration with Stripe
Here is a question for you: Your customer brings in the money, so why not have Zoho connected to Stripe? You have probably tried and found it difficult. But you keep trying. That’s why you are here.
You are tired of manually reconciling Zoho with Stripe and want a permanent solution. If that’s the case, you have come to the right place. I’m a webhook engineer who reconciles payment data with sales and customer management software such as Zoho, Go High Level, and Hubspot.
My secret sauce is Stripe webhooks which are notifications that Stripe creates and sends to receiving endpoints when there is a payment event. Some of the webhooks that I help connect to your system include:
payment_intent.succeeded tells you the moment a customer has paid. It updates a deal stage, marks an invoice as paid, or triggers a “welcome” sequence.
payment_intent.payment_failed tells you when a charge attempt didn’t go through. A failed payment tells you there is a possibility to win a customer over. So, it is not all negative.
charge.refunded keeps your CRM honest when money goes back out. Without this, a refunded customer can sit in your pipeline looking exactly like a paying one, which throws off everything from revenue reporting to customer health scoring.
invoice.payment_failed catches failed subscription renewals before they become silent churn. Catching this early gives you a window to intervene.
customer.subscription.updated keeps plan changes, upgrades, and downgrades synced. If a customer moves from your starter plan to your premium plan and Zoho doesn’t know, your team could be working off the wrong account value the next time they talk to that customer.
customer.subscription.deleted flags a cancellation the instant it happens, not whenever someone happens to notice the numbers are off.
If your customer handling system and payments processing don’t communicate, you are forced to manually copy data from Stripe and you are likely not tracking all the notifications above.
In this article, I will show you how I track every one of these and reconcile them with your customer data such that you have a single source of truth.
The architecture: Making Stripe and Zoho Communicate
The right model is direct and simple. Stripe fires events. Your server catches them. Your server writes to Zoho. That’s it.
The three-layer stack
You need three things:
• A webhook endpoint: a server route that receives Stripe events, verifies their signature, and returns HTTP 200 immediately.
• An event handler: logic that maps each Stripe event type to the correct Zoho operation — update a deal, create a note, change a contact field, create a follow-up task.
• A Zoho API client: authenticated access to Zoho CRM’s REST API to read and write records.
The key architectural decision is this: your webhook endpoint must respond to Stripe within 30 seconds or Stripe will consider the delivery failed and retry. So, the endpoint receives the event, acknowledges it immediately.
To make this happen you need to assign your customers in Zoho a matching Stripe Customer ID which you should store as a custom field on the Zoho Contact record the first time you create or identify a customer. Every Stripe event carries the customer ID. Your sync code uses that ID to look up the right Zoho record and update it.
Setting up the custom field in Zoho
Before writing a line of code, add a custom field to your Zoho CRM Contact module:
• Go to Zoho CRM Setup > Customization > Modules and Fields > Contacts
• Add a new Single Line field called Stripe Customer ID
• Set the field to read-only from the UI so it’s only ever written by your integration
• Note the API name — it will be something like Stripe_Customer_ID
From this point forward, every contact in Zoho that has a corresponding Stripe customer will have their customer ID stored here. This is the bridge.
Part 1: The Stripe webhook endpoint
Let’s build it. I’ll use Node.js and Express, but the concepts translate directly to any language.
Dependencies
The webhook receiver
This endpoint does two things: verifies the request came from Stripe, then hands the event off to your handler.
require('dotenv').config();
const express = require('express');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const { handleStripeEvent } = require('./eventHandler');
const app = express();
// Webhook route uses raw body - NOT express.json()
// express.json() parses the body and breaks Stripe's signature check
app.post('/webhooks/stripe',
express.raw({ type: 'application/json' }),
async (req, res) => {
const sig = req.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
console.error('Stripe signature verification failed:', err.message);
return res.status(400).send('Webhook signature invalid');
}
// Acknowledge immediately - Stripe has a 30-second timeout
res.status(200).json({ received: true });
// Handle asynchronously after responding
try {
await handleStripeEvent(event);
} catch (err) {
console.error('Event handling failed:', event.type, err.message);
// Log to your monitoring system here - do NOT re-throw
}
}
);
app.listen(3000);
Register your endpoint:. Copy the signing secret — that’s your STRIPE_WEBHOOK_SECRET.
To register an endpoint that will receive Stripe webhooks, go to Go to Stripe Dashboard > Developers > Webhooks > Add endpoint. Paste your URL (e.g. https://yourserver.com/webhooks/stripe)..) Select the webhooks you want your system to receive.
Part 2: Authenticating with Zoho CRM
Zoho uses OAuth 2.0. The flow for a server-to-server integration is straightforward: you generate a refresh token once from the Zoho developer console, then your code uses it to get short-lived access tokens automatically.
One-time setup in Zoho
-
Go to api-console.zoho.com and create a Server-based Application
-
Under Scopes, add: ZohoCRM.modules.ALL, ZohoCRM.settings.ALL
-
Generate a grant token using the “Generate Code” button
-
Exchange the grant token for a refresh token via Zoho’s token endpoint
-
Store the refresh token in your environment variables — this is long-lived
The Zoho API client
This module handles authentication transparently. Access tokens expire after an hour; this client refreshes them automatically.
// zohoClient.js
const axios = require('axios');
let accessToken = null;
let tokenExpiry = 0;
async function getAccessToken() {
if (accessToken && Date.now() < tokenExpiry) return accessToken;
const res = await axios.post('https://accounts.zoho.com/oauth/v2/token', null, {
params: {
refresh_token: process.env.ZOHO_REFRESH_TOKEN,
client_id: process.env.ZOHO_CLIENT_ID,
client_secret: process.env.ZOHO_CLIENT_SECRET,
grant_type: 'refresh_token',
}
});
accessToken = res.data.access_token;
tokenExpiry = Date.now() + (res.data.expires_in * 1000) - 60000; // 1 min buffer
return accessToken;
}
async function zoho(method, path, data) {
const token = await getAccessToken();
const base = 'https://www.zohoapis.com/crm/v6';
const res = await axios({
method,
url: `${base}${path}`,
headers: { Authorization: `Zoho-oauthtoken ${token}` },
data,
});
return res.data;
}
module.exports = { zoho };
Part 3: The event handler — mapping Stripe events to Zoho actions
This is the core of the integration. Each Stripe event type maps to a specific set of Zoho operations. I’ll cover the eight events that matter most for a service operations business.
The event handler
// eventHandler.js
const { zoho } = require('./zohoClient');
const { findContact, createNote, updateContact, createTask } = require('./zohoOps');
async function handleStripeEvent(event) {
const obj = event.data.object;
switch (event.type) {
case 'payment_intent.succeeded': {
const contact = await findContact(obj.customer);
if (!contact) return; // log and move on
const amount = (obj.amount / 100).toFixed(2);
await createNote(contact.id, 'Contacts',
`Payment received: $${amount} ${obj.currency.toUpperCase()} via Stripe.\n` +
`Payment ID: ${obj.id}\nDescription: ${obj.description ?? 'N/A'}`
);
break;
}
case 'payment_intent.payment_failed': {
const contact = await findContact(obj.customer);
if (!contact) return;
const reason = obj.last_payment_error?.message ?? 'Unknown';
await createNote(contact.id, 'Contacts',
`Payment FAILED: ${reason}\nAmount: $${(obj.amount/100).toFixed(2)}\nID: ${obj.id}`
);
await createTask(contact.id, 'Contacts',
`Follow up: failed payment for ${contact.Full_Name}`,
'High'
);
break;
}
case 'charge.dispute.created': {
const contact = await findContact(obj.customer);
if (!contact) return;
const amount = (obj.amount / 100).toFixed(2);
await createNote(contact.id, 'Contacts',
`DISPUTE FILED: $${amount} ${obj.currency.toUpperCase()}\n` +
`Reason: ${obj.reason}\nDispute ID: ${obj.id}\nDue: ${new Date(obj.evidence_details.due_by * 1000).toDateString()}`
);
await createTask(contact.id, 'Contacts',
`URGENT: Respond to chargeback for ${contact.Full_Name} by ${new Date(obj.evidence_details.due_by * 1000).toDateString()}`,
'Highest'
);
break;
}
case 'customer.subscription.deleted': {
const contact = await findContact(obj.customer);
if (!contact) return;
await updateContact(contact.id, {
Subscription_Status: 'Cancelled',
Subscription_End_Date: new Date(obj.canceled_at * 1000).toISOString().split('T')[0]
});
await createNote(contact.id, 'Contacts',
`Subscription cancelled. Reason: ${obj.cancellation_details?.reason ?? 'Not provided'}`
);
break;
}
default:
// Event received but not handled - this is fine
// Add new cases above as your needs grow
break;
}
}
Part 4: The Zoho operations layer
The event handler stays clean because the actual API calls are abstracted into a separate module. Here’s each helper function.
Finding a Zoho contact by Stripe Customer ID
This is the lookup that makes everything else possible. Every other operation depends on finding the right Zoho contact for the Stripe customer in the event.
// zohoOps.js
const { zoho } = require('./zohoClient');
// Search Zoho for a contact whose Stripe_Customer_ID matches
async function findContact(stripeCustomerId) {
if (!stripeCustomerId) return null;
const res = await zoho('GET',
`/Contacts/search?criteria=(Stripe_Customer_ID:equals:${stripeCustomerId})`
);
const contacts = res.data ?? [];
if (contacts.length === 0) {
console.warn(`No Zoho contact found for Stripe customer: ${stripeCustomerId}`);
return null;
}
// Return first match - deduplication is a separate concern
return contacts[0];
}
Creating a note on a record
async function createNote(parentId, module, content) {
await zoho('POST', '/Notes', {
data: [{
Note_Title: 'Stripe',
Note_Content: content,
Parent_Id: parentId,
se_module: module, // 'Contacts', 'Deals', 'Accounts'
}]
});
}
Creating a follow-up task
async function createTask(contactId, module, subject, priority = 'Normal') {
const due = new Date();
due.setDate(due.getDate() + 1); // Due tomorrow by default
await zoho('POST', '/Tasks', {
data: [{
Subject: subject,
Due_Date: due.toISOString().split('T')[0],
Priority: priority, // 'Highest' | 'High' | 'Normal' | 'Low' | 'Lowest'
Status: 'Not Started',
What_Id: { id: contactId, type: module },
}]
});
}
Updating contact fields
async function updateContact(contactId, fields) {
await zoho('PUT', '/Contacts', {
data: [{ id: contactId, …fields }]
});
}
Part 5: backfilling historical data
If your business has been running Stripe for months or years before building this integration, your Zoho CRM has a gap. Every payment, every failed charge, every cancellation that happened before you wired up webhooks is missing from Zoho’s contact records.
You need to backfill. And the right way to do it is to pull your Stripe customer list, match each customer to a Zoho contact, and replay the relevant history.
The backfill script
// backfill.js - run once to sync historical Stripe data into Zoho
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const { zoho } = require('./zohoClient');
async function backfill() {
let hasMore = true;
let startingAfter = null;
while (hasMore) {
// Fetch Stripe customers in pages of 100
const params = { limit: 100, expand: ['data.subscriptions'] };
if (startingAfter) params.starting_after = startingAfter;
const customers = await stripe.customers.list(params);
for (const customer of customers.data) {
// Find matching Zoho contact by email
const zohoRes = await zoho('GET',
`/Contacts/search?criteria=(Email:equals:${customer.email})`
);
const contact = zohoRes.data?.[0];
if (!contact) {
console.log(`No Zoho contact for: ${customer.email}`);
continue;
}
// Stamp the Stripe Customer ID onto the Zoho contact
await zoho('PUT', '/Contacts', {
data: [{ id: contact.id, Stripe_Customer_ID: customer.id }]
});
// Pull last 10 charges and log them as notes
const charges = await stripe.charges.list({ customer: customer.id, limit: 10 });
for (const charge of charges.data) {
if (charge.status !== 'succeeded') continue;
const amount = (charge.amount / 100).toFixed(2);
const date = new Date(charge.created * 1000).toDateString();
await zoho('POST', '/Notes', {
data: [{
Note_Title: 'Stripe (backfill)',
Note_Content: `Historical payment: $${amount} on ${date}. Charge ID: ${charge.id}`,
Parent_Id: contact.id,
se_module: 'Contacts',
}]
});
}
console.log(`Synced: ${customer.email}`);
}
hasMore = customers.has_more;
startingAfter = customers.data[customers.data.length - 1]?.id;
// Respect Zoho's rate limit - 100 API calls per minute
await new Promise(r => setTimeout(r, 700));
}
console.log('Backfill complete.');
}
backfill();
Part 6: Idempotency
Stripe will retry failed webhook deliveries up to three times over 24 hours. If your server returned a 500 error because Zoho was briefly unavailable, or you hit a rate limit Stripe will send the same event again.
Without idempotency protection, you’ll process the same payment event twice and create two notes on the Zoho contact for the same charge. Multiply that across a month of transactions and your contact records become noise.
Track processed events
// Simple in-memory deduplication (use Redis or a DB table in production)
const processedEvents = new Set();
async function handleStripeEvent(event) {
// Check if we've already handled this event
if (processedEvents.has(event.id)) {
console.log('Duplicate event skipped:', event.id);
return;
}
// Mark as processed before doing the work
// (if it fails, Stripe will retry and we'll try again)
processedEvents.add(event.id);
// … rest of the event handling
}
// Production version - store in database
// CREATE TABLE processed_stripe_events (
// event_id VARCHAR(255) PRIMARY KEY,
// processed_at TIMESTAMP DEFAULT NOW()
// );
//
// Before handling: SELECT 1 FROM processed_stripe_events WHERE event_id = $1
// After handling: INSERT INTO processed_stripe_events (event_id) VALUES ($1)
Part 7: Testing the integration end to end
Before pointing this at live Stripe events, test it locally. Stripe CLI makes this straightforward.
Local testing with Stripe CLI
Terminal 1 — start your server
node server.js
Terminal 2 — forward Stripe events to localhost
stripe listen — forward-to localhost:3000/webhooks/stripe
Terminal 3 — trigger specific test events
stripe trigger payment_intent.succeeded
stripe trigger payment_intent.payment_failed
stripe trigger charge.dispute.created
stripe trigger customer.subscription.deleted
What to verify after each test event
• Your server logs show the event was received
• The signature verification passed — no 400 errors
• The Zoho API call was made — check your zohoClient logs
• The correct Zoho record was found — right contact, right module
• The note or task appears in Zoho with the right content
• Triggering the same event twice creates only one Zoho record
Part 8: Making Zoho your single source of truth
Once the webhook integration is running, the question becomes: how do you surface the Stripe data you’re syncing in a way that’s actually useful inside Zoho?
Build a Payments subpanel on the Contact view
The way I do this is by adding related lists to Contact records. Instead of viewing payment notes in the Notes tab mixed with everything else, I build a dedicated Stripe Payments related list that shows only payment events.
I create a custom module called Stripe Payments with fields for amount, status, date, Stripe charge ID, and description.
The fields include:
• Total Revenue (Currency)
• Last Payment Date (Date)
• Last Payment Amount (Currency)
• Subscription Status (Picklist: Active / Past Due / Cancelled / None)
• Subscription Renewal Date (Date)
I know I have not covered every detail but that what you have is enough to help you configure a working solution that will help you reconcile your payments data with your customers data. What you will save is money plus time.
메타데이터
- post_id
- 8bb56d15b2d3
- slug
- stripe-zoho-crm-how-to-reconcile-payments-and-customer-data-8bb56d15b2d3
- url
- https://medium.com/@gholaworks/stripe-zoho-crm-how-to-reconcile-payments-and-customer-data-8bb56d15b2d3
- canonical_url
- https://medium.com/@gholaworks/stripe-zoho-crm-how-to-reconcile-payments-and-customer-data-8bb56d15b2d3
- author_url
- https://medium.com/@gholaworks
- status
- ok
- fetched_at
- 2026-07-13 11:44:12