<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>OAuth | John Nessime</title>
	<atom:link href="https://john-nessime.com/blog/tag/oauth/feed/" rel="self" type="application/rss+xml" />
	<link>https://john-nessime.com/blog/tag/oauth/</link>
	<description>Cloud, DevOps, Data &#38; AI — Built, Tested, Explained</description>
	<lastBuildDate>Sun, 02 Aug 2026 08:57:57 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.0.2</generator>

<image>
	<url>https://john-nessime.com/blog/wp-content/uploads/2026/07/cropped-jn-32x32.png</url>
	<title>OAuth | John Nessime</title>
	<link>https://john-nessime.com/blog/tag/oauth/</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>It Works Isn&#8217;t Ready: A Production Checklist for Salesforce AWS Integrations</title>
		<link>https://john-nessime.com/blog/devops/salesforce-aws-integration-checklist/</link>
					<comments>https://john-nessime.com/blog/devops/salesforce-aws-integration-checklist/#respond</comments>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Mon, 03 Aug 2026 21:53:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[AWS Lambda]]></category>
		<category><![CDATA[Cloud Security]]></category>
		<category><![CDATA[CloudWatch]]></category>
		<category><![CDATA[Cost Optimization]]></category>
		<category><![CDATA[Data Integration]]></category>
		<category><![CDATA[ETL]]></category>
		<category><![CDATA[Infrastructure]]></category>
		<category><![CDATA[Monitoring]]></category>
		<category><![CDATA[OAuth]]></category>
		<category><![CDATA[Production]]></category>
		<category><![CDATA[Salesforce]]></category>
		<category><![CDATA[SRE]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=107</guid>

					<description><![CDATA[<p>"It works" and "it's ready" are different claims. Integrations rarely fail on launch day; they fail in week six, when the token expires and someone adds a field. A readiness checklist for Salesforce AWS integrations, with a pass test on every item.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/salesforce-aws-integration-checklist/">It Works Isn&#8217;t Ready: A Production Checklist for Salesforce AWS Integrations</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Someone asks whether the integration is ready to go live. The answer that comes back is &#8220;it works.&#8221; Those are different statements, and the gap between them is where the next three months of interruptions live.</p>



<p class="wp-block-paragraph">Integrations very rarely fail on launch day. They fail in week six. That is roughly when the OAuth token first expires, when an admin adds a field nobody mentioned, when a second tool starts consuming the same API allowance, and when enough records have been deleted for the row counts to visibly diverge. None of those conditions exist in staging, which is why staging said yes.</p>



<p class="wp-block-paragraph">So this is a <strong>Salesforce AWS integration checklist</strong> aimed at week six rather than at Tuesday. Every item has a pass test, because &#8220;we thought about that&#8221; is not the same as &#8220;we checked.&#8221;</p>



<h2 class="wp-block-heading">1. Credentials and identity</h2>



<ul class="wp-block-list">
<li><strong>The integration has its own Salesforce user.</strong> <em>Pass: the username is not a person&#8217;s, and nobody uses it interactively.</em></li>
<li><strong>That user is API-only and cannot log in through the UI.</strong> <em>Pass: someone tried and was refused.</em></li>
<li><strong>Its profile grants only the objects and fields you actually read.</strong> <em>Pass: you have the field-level permission list and can explain every entry.</em></li>
<li><strong>Credentials live in a secrets manager, not in environment variables or config files.</strong> <em>Pass: the secret is retrievable by ARN and nothing in the repo contains it.</em></li>
<li><strong>You have rotated the credential at least once, in production, and the pipeline survived.</strong> <em>Pass: there is a dated record of the rotation and no incident attached to it.</em></li>
<li><strong>You know what invalidates the refresh token.</strong> <em>Pass: you can name the events that revoke it, including password resets and session policy changes on the integration user.</em></li>
</ul>



<p class="wp-block-paragraph">That last item is the classic week-four failure. A refresh token feels permanent right up until an admin does something routine to the user account it belongs to.</p>



<h2 class="wp-block-heading">2. Network, versions and deadlines</h2>



<ul class="wp-block-list">
<li><strong>You know whether the compute needs to be in a VPC, and why.</strong> <em>Pass: if it is, you can name the private resource it reaches.</em></li>
<li><strong>If it is in a VPC, S3 traffic goes through a gateway endpoint.</strong> <em>Pass: the endpoint exists and the route table references it.</em></li>
<li><strong>The Salesforce API version in your endpoints is pinned and current.</strong> <em>Pass: it is not a version on a retirement list.</em></li>
<li><strong>IAM permissions are scoped to the specific resources involved.</strong> <em>Pass: no wildcard on S3 buckets or Secrets Manager paths.</em></li>
<li><strong>Login IP restrictions and connected app policies are configured deliberately.</strong> <em>Pass: you know whether IP relaxation is on and made that choice on purpose.</em></li>
</ul>



<p class="wp-block-paragraph">The API version item has real dates attached, so it is worth stating plainly rather than leaving as a vague &#8220;keep current&#8221;. Salesforce retired versions 7.0 through 20.0 in Summer &#8217;22 and versions 21.0 through 30.0 in Summer &#8217;25, and has announced that 31.0 through 40.0 retire in June 2028, with the SOAP <code>login()</code> call retiring separately in June 2027. Retired versions return HTTP 410 on REST rather than degrading gracefully.</p>



<p class="wp-block-paragraph">Usefully, Salesforce warns you first. Calls to legacy versions come back with a <code>Warning</code> header, so this is detectable today rather than on the morning it breaks:</p>



<pre class="wp-block-code"><code># If this matches anything, you have a deadline you did not know about.
grep -i "Warning:" integration.log | sort -u</code></pre>



<h2 class="wp-block-heading">3. Correctness</h2>



<ul class="wp-block-list">
<li><strong>Deletes are handled, not just creates and updates.</strong> <em>Pass: you can point at the code path and name the mechanism it uses.</em></li>
<li><strong>Merged records do not double-count.</strong> <em>Pass: you have checked what happens to the losing record of a merge.</em></li>
<li><strong>Formula and computed fields are either recalculated downstream or documented as snapshots.</strong> <em>Pass: there is a written list of which columns are derived.</em></li>
<li><strong>Re-running the same window produces the same result.</strong> <em>Pass: you ran it twice and diffed the output.</em></li>
<li><strong>One real aggregate reconciles against Salesforce for a closed period.</strong> <em>Pass: daily and monthly totals both match, not just daily.</em></li>
<li><strong>Timezone handling is explicit at the boundary.</strong> <em>Pass: someone can say which timezone the date column is in without guessing.</em></li>
</ul>



<p class="wp-block-paragraph">If daily figures reconcile and monthly ones do not, that is a timezone problem at period boundaries, not missing data. Find it now, because finding it inside somebody&#8217;s board pack is a worse day.</p>



<h2 class="wp-block-heading">4. Failure handling</h2>



<ul class="wp-block-list">
<li><strong>Retries are scoped to failures that deserve them.</strong> <em>Pass: transient errors retry, data errors do not, and you can show which is which.</em></li>
<li><strong>Backoff is exponential with jitter.</strong> <em>Pass: a fixed sleep does not appear anywhere in the retry path.</em></li>
<li><strong>A partial failure leaves recoverable state.</strong> <em>Pass: the watermark advances only after data lands, not when the API call returns.</em></li>
<li><strong>You can replay an arbitrary historical window on demand.</strong> <em>Pass: someone has done it, in production, with a command you could hand to a colleague.</em></li>
<li><strong>Rate limiting is treated as an expected condition.</strong> <em>Pass: a 429 or a limit error produces a backoff, not an alert and a failed run.</em></li>
</ul>



<h2 class="wp-block-heading">5. Observability</h2>



<ul class="wp-block-list">
<li><strong>The pipeline emits a metric on successful completion.</strong> <em>Pass: the metric exists and you can graph it.</em></li>
<li><strong>An alarm fires when that metric stops arriving.</strong> <em>Pass: you disabled the schedule on purpose and the alarm went red.</em></li>
<li><strong>Data freshness is measured on the destination side.</strong> <em>Pass: you can answer &#8220;how far behind are we right now&#8221; from a dashboard.</em></li>
<li><strong>Salesforce API allowance consumption is graphed.</strong> <em>Pass: there is a percentage on a chart with a threshold line.</em></li>
<li><strong>Alerts describe symptoms and link to a runbook.</strong> <em>Pass: the alarm description contains a URL somebody could follow at 3am.</em></li>
</ul>



<p class="wp-block-paragraph">The second item is the one people skip and it is the only one that matters. An alarm you have never seen fire is a belief, not a control.</p>



<h2 class="wp-block-heading">6. Cost</h2>



<ul class="wp-block-list">
<li><strong>Every resource carries a cost allocation tag.</strong> <em>Pass: Cost Explorer filtered to that tag returns a number.</em></li>
<li><strong>Log groups have a retention period.</strong> <em>Pass: none of them say &#8220;Never expire&#8221;.</em></li>
<li><strong>The schedule matches how the output is actually used.</strong> <em>Pass: somebody named the decision the data supports and the latency it tolerates.</em></li>
<li><strong>Average object size in the destination is sensible.</strong> <em>Pass: you have looked, and it is not thousands of tiny files per partition.</em></li>
<li><strong>A budget alert exists on the pipeline&#8217;s tag.</strong> <em>Pass: it has a threshold and a recipient who is not on holiday.</em></li>
</ul>



<h2 class="wp-block-heading">7. Operations and handover</h2>



<ul class="wp-block-list">
<li><strong>The integration has a named owner.</strong> <em>Pass: a person, not a team inbox, and they know.</em></li>
<li><strong>A runbook exists covering the three most likely failures.</strong> <em>Pass: someone who did not build it followed the runbook successfully.</em></li>
<li><strong>Infrastructure is defined as code.</strong> <em>Pass: you could rebuild it in a fresh account from the repository.</em></li>
<li><strong>Schema drift produces a notification.</strong> <em>Pass: adding a field in a sandbox generated an alert somewhere.</em></li>
<li><strong>Personal data has an access policy and a retention policy.</strong> <em>Pass: both are written down and somebody outside the team has read them.</em></li>
<li><strong>You know what a Salesforce sandbox refresh does to it.</strong> <em>Pass: you have been through one, or you know what will break when you do.</em></li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">If you only do five</h2>



<p class="wp-block-paragraph">Nobody clears a thirty-item list before a deadline. These five catch most of what actually goes wrong:</p>



<ol class="wp-block-list">
<li><strong>Rotate the credential once before go-live.</strong> It proves the whole credential path works and it is the failure with the longest fuse.</li>
<li><strong>Break it deliberately and confirm someone is told.</strong> Disable the schedule, watch the alarm, put it back.</li>
<li><strong>Reconcile one real number</strong> against Salesforce for a closed period, daily and monthly.</li>
<li><strong>Set log retention and one cost allocation tag.</strong> Five minutes, and it is the difference between a knowable bill and a mystery.</li>
<li><strong>Write the runbook and name the owner.</strong> If it is nobody&#8217;s, it is nobody&#8217;s at 3am too.</li>
</ol>



<h2 class="wp-block-heading">FAQ</h2>



<h3 class="wp-block-heading">What actually breaks first in production?</h3>



<p class="wp-block-paragraph">Credentials, usually around the first expiry or the first time an admin touches the integration user. After that, schema drift and API allowance contention with a tool somebody else added.</p>



<h3 class="wp-block-heading">How do I know if I&#8217;m on a retiring API version?</h3>



<p class="wp-block-paragraph">Look at the version string in your endpoint URLs, and search your logs for the <code>Warning</code> header Salesforce returns on legacy versions. Retired versions fail with an HTTP 410 rather than degrading, so there is no soft landing.</p>



<h3 class="wp-block-heading">Is a staging environment enough to sign off?</h3>



<p class="wp-block-paragraph">No, because the conditions that break integrations barely exist there: real volume, real credential lifecycles, real deletions, and a shared API budget. Staging proves the code runs. It cannot prove the thing survives contact with an organisation.</p>



<h3 class="wp-block-heading">How often should I revisit the list?</h3>



<p class="wp-block-paragraph">Quarterly for the alarm test and the reconciliation, annually for permissions, credentials and API versions. Put both in a calendar, because an integration that works is one nobody thinks about until it does not.</p>



<h3 class="wp-block-heading">Does this apply to managed connectors too?</h3>



<p class="wp-block-paragraph">Most of it. You inherit retries and pagination from the vendor, but credentials, permissions, reconciliation, cost, alerting and ownership are all still yours. A managed connector removes code, not accountability.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">The one thing to remember</h2>



<p class="wp-block-paragraph">&#8220;It works&#8221; is a statement about today. &#8220;It&#8217;s ready&#8221; is a claim about week six, and the only honest way to make it is to have tested the things that only happen later: rotate the credential, break the schedule, reconcile a number, follow the runbook.</p>



<p class="wp-block-paragraph">If you cannot point at evidence for an item, it is not done. It is intended, which is a different thing, and the gap between the two is where you will spend your next quarter.</p>



<h2 class="wp-block-heading">Want a second pair of eyes before go-live?</h2>



<p class="wp-block-paragraph">Readiness reviews are cheap compared with the incidents they prevent, and they go faster with someone who has seen the same six failures repeatedly. Work I take on:</p>



<ul class="wp-block-list">
<li>Pre-launch readiness review against this checklist, with evidence collected rather than assurances taken.</li>
<li>Credential and permission audit: integration users, profiles, field-level security, secret storage and rotation.</li>
<li>Reconciliation harness so row counts and aggregates are checked automatically rather than when someone complains.</li>
<li>Alerting and runbooks, including a failure drill that proves the alerts reach a human.</li>
<li>Legacy API version audits ahead of the announced retirement deadlines.</li>
<li>Cost tagging, log retention and budget alerts so the first month&#8217;s bill is not a surprise.</li>
</ul>



<p class="wp-block-paragraph">Tell me which items on this list you have evidence for, and I will tell you which of the rest matter most for your setup.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/devops/salesforce-aws-integration-checklist/">It Works Isn&#8217;t Ready: A Production Checklist for Salesforce AWS Integrations</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://john-nessime.com/blog/devops/salesforce-aws-integration-checklist/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Run It Twice, Get Two Answers: Building an ETL Pipeline From Zoho CRM to Amazon S3</title>
		<link>https://john-nessime.com/blog/devops/zoho-crm-to-amazon-s3/</link>
					<comments>https://john-nessime.com/blog/devops/zoho-crm-to-amazon-s3/#respond</comments>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sun, 02 Aug 2026 18:16:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Amazon Athena]]></category>
		<category><![CDATA[Amazon S3]]></category>
		<category><![CDATA[Apache Iceberg]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[AWS Glue]]></category>
		<category><![CDATA[Cloud]]></category>
		<category><![CDATA[Data Engineering]]></category>
		<category><![CDATA[Data Integration]]></category>
		<category><![CDATA[Data Lake]]></category>
		<category><![CDATA[ETL]]></category>
		<category><![CDATA[Infrastructure]]></category>
		<category><![CDATA[OAuth]]></category>
		<category><![CDATA[Python]]></category>
		<category><![CDATA[REST API]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[Zoho]]></category>
		<category><![CDATA[Zoho CRM]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=86</guid>

					<description><![CDATA[<p>You re-run the same extract for the same window and get a different set of rows. Nothing errored. You were paginating a result set that kept changing while you read it. Here's how to build a Zoho CRM to S3 pipeline whose runs are repeatable, from closed read windows to Bulk Read and deletions.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/zoho-crm-to-amazon-s3/">Run It Twice, Get Two Answers: Building an ETL Pipeline From Zoho CRM to Amazon S3</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The monthly deal count in your warehouse is short by about thirty rows. You re-run the extract for exactly the same window, expecting to confirm the bug, and this time you get a different thirty. Run it a third time and the number moves again.</p>



<p class="wp-block-paragraph">Nothing is broken in the way you are looking for. The records exist in Zoho. Your job did not error. What happened is that you asked for every deal modified since your last watermark, sorted, and then walked through it page by page while sales reps kept working. Records got modified during the read, changed their position in the sort order, and shifted from page 4 to page 2 after you had already read page 2. So you never saw them.</p>



<p class="wp-block-paragraph">That is the failure that defines building an <strong>ETL pipeline from Zoho CRM to Amazon S3</strong>, and it does not look like a bug. It looks like flaky data. It only shows up on busy modules, it never produces an error, and re-running the job produces a different wrong answer, which is the single most confusing symptom in data engineering.</p>



<p class="wp-block-paragraph">The fix is one rule, and everything else in this post follows from it. This covers that rule, which extraction API to use and when, authentication and the data centre trap that wastes an afternoon, handling deletions (Zoho is genuinely good here), shaping nested CRM JSON for S3, and staying inside your API credits.</p>



<h2 class="wp-block-heading">The rule: bound both ends of the window</h2>



<p class="wp-block-paragraph">Almost every incremental extract is written like this:</p>



<pre class="wp-block-code"><code>Modified_Time &gt; {last_run}</code></pre>



<p class="wp-block-paragraph">That query has no upper bound, which means the result set keeps growing while you read it. You are paginating a moving target. Add an upper bound and put it slightly in the past:</p>



<pre class="wp-block-code"><code>Modified_Time &gt; {last_run} AND Modified_Time &lt; {now_minus_lag}</code></pre>



<p class="wp-block-paragraph">Now the set is frozen. Records modified during your run land after the upper bound and get picked up next time. The extract becomes repeatable: run it twice, get the same rows twice. That property is worth more than any amount of retry logic, because it means a failed run costs you nothing and a suspicious number can be checked by re-running.</p>



<p class="wp-block-paragraph">Two details that matter. The lag needs to comfortably exceed how long your extract takes plus any clock skew between you and Zoho; ten minutes is a sensible starting point and costs you ten minutes of freshness. And <strong>only advance the watermark after the entire window has landed in S3</strong>, never after the API call succeeds. Those are different moments, and the gap between them is where data goes missing.</p>



<pre class="wp-block-code"><code>#!/usr/bin/env bash
set -euo pipefail

LAG_MINUTES=10
T1=$(cat state/deals.watermark)
T2=$(date -u -d "-${LAG_MINUTES} minutes" +%Y-%m-%dT%H:%M:%S+00:00)

extract_window "$T1" "$T2"

# Watermark advances only once the data is durably in S3.
echo "$T2" &gt; state/deals.watermark</code></pre>



<p class="wp-block-paragraph">Keep that state somewhere durable and versioned, not on the box running the job. DynamoDB, Parameter Store, or a small object in S3 all work. A watermark file on an ephemeral runner is a watermark you will lose.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Authentication, and the trap that costs an afternoon</h2>



<p class="wp-block-paragraph">Zoho uses OAuth 2.0. You register a client in the Zoho API Console, get a client ID and secret, exchange a grant token for a refresh token once, and then trade the refresh token for short-lived access tokens from then on. Access tokens last an hour, so cache them rather than requesting one per call.</p>



<p class="wp-block-paragraph">Here is the part that catches everyone: <strong>Zoho runs multiple data centres and they are separate worlds.</strong> An account in the EU data centre authenticates against a different accounts domain and calls a different API domain than a US account. A token issued in one region is meaningless in another, and the error you get back does not say &#8220;wrong data centre&#8221;, it says the token is invalid.</p>



<p class="wp-block-paragraph">So make the domain a configuration value from the first line of code, never a hardcoded string. The examples below use the US domain. If your org lives elsewhere, both the accounts domain and the API domain change together.</p>



<p class="wp-block-paragraph">Two more things worth getting right at the start. Request the narrowest OAuth scopes that work, read-only on the modules you actually extract, because scopes are easy to widen later and awkward to explain in an audit. And store the refresh token in Secrets Manager rather than an environment variable, since it does not expire and is effectively a permanent key to your CRM.</p>



<h2 class="wp-block-heading">Which API to extract with</h2>



<p class="wp-block-paragraph">Three options, and the choice is mostly about volume.</p>



<h3 class="wp-block-heading">Bulk Read, for anything large</h3>



<p class="wp-block-paragraph">This is the right default for a lake. You POST a job description, Zoho runs the export server-side, and you poll for status or supply a callback URL. When it finishes you get a download URL for a ZIP of CSV. A single job handles up to 200,000 records, with paging beyond that, and it does not consume your standard API limits the way record-by-record calls do.</p>



<p class="wp-block-paragraph">The important property for our purposes: the pagination problem disappears. Zoho assembles the export on its side, so there is no page-by-page read for records to slip between.</p>



<pre class="wp-block-code"><code>curl "https://www.zohoapis.com/crm/bulk/v8/read" 
  -X POST 
  -H "Authorization: Zoho-oauthtoken $ACCESS_TOKEN" 
  -H "Content-Type: application/json" 
  -d @job.json</code></pre>



<pre class="wp-block-code"><code>{
  "query": {
    "module": { "api_name": "Deals" },
    "fields": ["id", "Deal_Name", "Amount", "Stage", "Modified_Time"],
    "criteria": {
      "group_operator": "and",
      "group": [
        {
          "field": { "api_name": "Modified_Time" },
          "comparator": "greater_than",
          "value": "{T1}"
        },
        {
          "field": { "api_name": "Modified_Time" },
          "comparator": "less_than",
          "value": "{T2}"
        }
      ]
    },
    "page": 1
  }
}</code></pre>



<p class="wp-block-paragraph">Two limits to design around. The download URL is only valid for about a day, so fetch and land the file promptly rather than queueing it for a later step. And downloads are rate limited per minute, so if you fan out across many modules at once you will start collecting 429s.</p>



<h3 class="wp-block-heading">COQL, for moderate volumes and real filtering</h3>



<p class="wp-block-paragraph">Zoho&#8217;s SQL-like query API. Up to 2,000 records per call and up to 100,000 records total per unique criteria through pagination. Past that, Zoho&#8217;s own documentation tells you to use Bulk Read, which is good advice to take rather than work around.</p>



<p class="wp-block-paragraph">If you do paginate COQL, paginate by key rather than by offset. Sort by <code>id</code> and carry the last ID you saw into the next call, so shifting records cannot cause a skip:</p>



<pre class="wp-block-code"><code>{
  "select_query": "select id, Deal_Name, Amount, Stage, Modified_Time from Deals where (Modified_Time &gt; '{T1}' and Modified_Time &lt; '{T2}') and id &gt; {last_id} order by id asc limit 2000"
}</code></pre>



<p class="wp-block-paragraph">Note the shape: a closed time window that freezes the set, plus a keyset cursor that walks it deterministically. Offset pagination gives you neither. Zoho&#8217;s own documentation uses this pattern in its examples, which is a fair hint about what they expect.</p>



<h3 class="wp-block-heading">Get Records, for small modules and metadata</h3>



<p class="wp-block-paragraph">The plain module endpoint returns up to 200 records a page and supports an <code>If-Modified-Since</code> header for incremental reads. Fine for lookup tables and small custom modules. Not what you want pointed at a Deals module with six figures of rows.</p>



<p class="wp-block-paragraph">Worth saying plainly: if all of this sounds like a fortnight of work you would rather not own, a managed connector from Fivetran or Airbyte solves the extraction half and you spend your time on the modelling instead. Price it against your own hours honestly, because the build-it-yourself option is habitually costed at zero.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Deletions, which Zoho actually handles well</h2>



<p class="wp-block-paragraph">An incremental extract can never see a deletion, because a record that no longer exists cannot appear in a query for modified records. Most CRMs make you infer this. Zoho gives you a direct endpoint, and it is one of the nicer parts of the API.</p>



<pre class="wp-block-code"><code># type: all | recycle | permanent
curl "https://www.zohoapis.com/crm/v8/Deals/deleted?type=all&amp;per_page=200&amp;page=1" 
  -X GET 
  -H "Authorization: Zoho-oauthtoken $ACCESS_TOKEN" 
  -H "If-Modified-Since: {T1}"</code></pre>



<p class="wp-block-paragraph">The retention windows are generous: records in the recycle bin are retrievable for up to 60 days from deletion, and permanently deleted records for up to 120 days. Compared with warehouses where the detection window is a couple of weeks and not guaranteed, that is a lot of slack. It also means there is no excuse for missing a deletion, since any sane sync interval sits comfortably inside it.</p>



<p class="wp-block-paragraph">Run the deleted-records call as part of every extract cycle, using the same closed window, and mark the matching rows as deleted in your curated layer rather than removing them from raw. Soft-delete preserves the audit trail and lets you answer &#8220;when did this disappear&#8221; later.</p>



<p class="wp-block-paragraph">Even with a good endpoint, run a periodic full ID reconciliation as a backstop, monthly is plenty. Pull just the ID column for the whole module, diff it against the lake, and flag anything you still believe in that Zoho does not. It catches the cases the deletion endpoint does not describe cleanly, such as records merged or moved between modules.</p>



<h2 class="wp-block-heading">Shaping CRM data for S3</h2>



<p class="wp-block-paragraph">Land raw first, exactly as returned, in its own prefix. Then convert to Parquet in a second step. Skipping the raw layer feels efficient right up to the first time you need to reprocess a month with corrected logic.</p>



<pre class="wp-block-code"><code># Partition by the window's upper bound, not by a business date.
# One bad run is then one partition to replace.
s3://acme-lake/raw/zoho_crm/deals/window_end=YYYY-MM-DDTHH/
s3://acme-lake/curated/zoho_crm/deals/window_end=YYYY-MM-DDTHH/</code></pre>



<p class="wp-block-paragraph">Zoho returns nested structures that do not map onto a flat table. Lookup fields come back as objects with an ID and a display name. Multi-select fields and tags come back as arrays. Subforms come back as arrays of objects. You have three choices per field and should make each one deliberately:</p>



<ul class="wp-block-list">
<li><strong>Flatten into columns.</strong> A lookup becomes <code>account_id</code> and <code>account_name</code>. Correct for anything you filter or join on.</li>
<li><strong>Split into a child table.</strong> Subforms and line items become their own Parquet dataset keyed by parent ID. Correct when the nested rows are things people count.</li>
<li><strong>Keep as a JSON string column.</strong> Fine for rarely-queried arrays, and Athena can parse it on demand. Not fine for anything in a regular report.</li>
</ul>



<p class="wp-block-paragraph">Pin your types explicitly during conversion rather than letting the writer infer them from a batch. Inference is the reason a column is a string in January&#8217;s files and a double in February&#8217;s, and Athena will happily read both and quietly fail to reconcile them.</p>



<p class="wp-block-paragraph">Register the result in the Glue Data Catalog and query with Athena. If you expect to apply updates and deletes in place rather than rebuilding partitions, Apache Iceberg earns its extra setup: row-level operations and schema evolution are exactly what a CRM feed generates.</p>



<p class="wp-block-paragraph">On the AWS side, the orchestration is unremarkable and should stay that way: EventBridge on a schedule, Lambda or a small container for the extract, Step Functions if you need to poll a Bulk Read job and branch on the result. Resist Glue jobs for the extraction itself unless you are already deep in Glue; a Python container you can run locally is easier to debug at seven in the morning.</p>



<h2 class="wp-block-heading">API credits</h2>



<p class="wp-block-paragraph">Zoho meters API usage as credits, allocated by edition and user count, over a rolling 24-hour window, and shared with every other integration on the org. There are also concurrency limits, so hammering the API in parallel fails differently from exceeding your daily allowance.</p>



<ul class="wp-block-list">
<li><strong>Bulk Read for volume.</strong> It sidesteps the standard limits and is the whole reason the API exists.</li>
<li><strong>Ask for fewer fields.</strong> Selecting only the columns you actually land reduces payload and processing on both sides.</li>
<li><strong>Mind the COQL limit value.</strong> Credits scale with the page size you request, so grabbing 2,000 rows costs more than grabbing 200. Larger pages are still usually the better trade, but know you are making it.</li>
<li><strong>Give the pipeline its own connected app and user.</strong> Then consumption is attributable when someone asks who exhausted the credits.</li>
<li><strong>Back off properly on 429.</strong> Exponential backoff with jitter, not a fixed sleep, and treat rate limiting as an expected condition rather than an error.</li>
</ul>



<h2 class="wp-block-heading">Troubleshooting</h2>



<h3 class="wp-block-heading">Two runs of the same window return different rows</h3>



<p class="wp-block-paragraph">Open-ended window, offset pagination, or both. Add the upper bound, switch to keyset pagination or Bulk Read, and the symptom disappears. This is the one to check before anything else, because it makes every other investigation unreliable.</p>



<h3 class="wp-block-heading">Invalid token, but the credentials are definitely right</h3>



<p class="wp-block-paragraph">Data centre mismatch. Confirm which region the org lives in and that your accounts domain and API domain both match it. This is far more common than an actually bad token.</p>



<h3 class="wp-block-heading">Row counts drift upward over time</h3>



<p class="wp-block-paragraph">Deletions are not being applied. Run the deleted-records endpoint for the last 60 days and see how much comes back, then run a full ID reconciliation to catch the rest.</p>



<h3 class="wp-block-heading">Bulk Read job finished but the file is gone</h3>



<p class="wp-block-paragraph">The download URL expires after roughly a day. Land the file as soon as the job reports complete, rather than deferring it to a downstream step that might not run until tomorrow.</p>



<h3 class="wp-block-heading">Athena fails with a schema mismatch across partitions</h3>



<p class="wp-block-paragraph">Type inference changed between runs, usually because a nullable field was all-null in one batch and populated in the next. Define the schema explicitly at write time and reprocess the affected partitions from raw.</p>



<h3 class="wp-block-heading">A new custom field never appears</h3>



<p class="wp-block-paragraph">You are selecting fields explicitly, which is correct, and nobody told you a field was added. Poll the module&#8217;s field metadata on a schedule and alert on changes, so schema drift is a notification rather than a discovery six weeks later.</p>



<h3 class="wp-block-heading">Frequent 429s</h3>



<p class="wp-block-paragraph">Either concurrency or the download rate limit, depending on which call is failing. Serialise the module extracts rather than fanning them all out at once, and add jittered backoff.</p>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>An open-ended <code>Modified_Time</code> filter with no upper bound.</li>
<li>Offset pagination over a dataset that is being modified while you read it.</li>
<li>Advancing the watermark when the API call succeeds rather than when the data lands.</li>
<li>Keeping watermark state on an ephemeral runner.</li>
<li>Hardcoding the Zoho API domain and discovering data centres the hard way.</li>
<li>Never calling the deleted-records endpoint, so counts only ever grow.</li>
<li>Deferring the Bulk Read download until after the URL has expired.</li>
<li>Landing only Parquet with no raw layer, so reprocessing means re-extracting.</li>
<li>Letting the Parquet writer infer types per batch.</li>
<li>Flattening subforms into a wide table and double-counting parent rows.</li>
<li>Fanning out every module in parallel and collecting rate limits.</li>
<li>Sharing the pipeline&#8217;s connected app with other integrations, so nobody can attribute credit usage.</li>
<li>Storing the refresh token in an environment variable and forgetting it never expires.</li>
</ul>



<h2 class="wp-block-heading">Best practices</h2>



<ul class="wp-block-list">
<li>Closed read windows with a deliberate lag, always.</li>
<li>Keyset pagination when you paginate at all, Bulk Read when you can avoid it.</li>
<li>Watermarks in durable storage, advanced only on confirmed landing.</li>
<li>Idempotent runs, so re-running a window is always safe.</li>
<li>Deleted-records endpoint every cycle, full ID reconciliation monthly.</li>
<li>Raw layer untouched, curated layer derived, partitions keyed by window end.</li>
<li>Explicit schemas on write, explicit field lists on read.</li>
<li>Its own connected app, narrow scopes, refresh token in Secrets Manager.</li>
<li>Jittered exponential backoff and serialised module extracts.</li>
<li>Alerting on schema drift and on row-count delta against Zoho.</li>
<li>Encryption and a retention policy on CRM data in S3 from day one, since it is personal data.</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">FAQ</h2>



<h3 class="wp-block-heading">What is the best way to export Zoho CRM data to S3?</h3>



<p class="wp-block-paragraph">Bulk Read for anything of size, on a schedule, with closed time windows. COQL for moderate volumes where you want real filtering. The plain records endpoint only for small modules. Land raw output first, convert to Parquet second.</p>



<h3 class="wp-block-heading">Why does my extract return different results each run?</h3>



<p class="wp-block-paragraph">Your query has no upper time bound, so the result set changes while you paginate through it. Bound both ends of the window and put the upper bound a few minutes in the past. That single change makes runs repeatable.</p>



<h3 class="wp-block-heading">How do I capture deleted records?</h3>



<p class="wp-block-paragraph">Call the module&#8217;s deleted-records endpoint each cycle. Recycle-bin deletions stay retrievable for 60 days and permanent deletions for 120, so a daily or hourly sync has plenty of margin. Add a monthly full ID reconciliation as a backstop.</p>



<h3 class="wp-block-heading">Will this exhaust my Zoho API credits?</h3>



<p class="wp-block-paragraph">Not if you use Bulk Read for the heavy lifting, since it does not draw on standard API limits the way per-record calls do. The credit pool is shared across the org, so give the pipeline its own connected app and monitor consumption rather than finding out when someone else&#8217;s integration breaks.</p>



<h3 class="wp-block-heading">How do I handle subforms and multi-select fields?</h3>



<p class="wp-block-paragraph">Decide per field. Flatten lookups into ID and name columns, split subforms and line items into their own child datasets keyed by parent ID, and keep genuinely peripheral arrays as JSON strings. Flattening a subform into the parent row is how you end up double-counting deals.</p>



<h3 class="wp-block-heading">Build it or buy a connector?</h3>



<p class="wp-block-paragraph">Buy it if Zoho CRM is one source among several and you have no unusual requirements; managed connectors handle pagination, deletes and schema drift, which is most of the work described here. Build it when you need control over the shape of the output, want to avoid per-row pricing, or already run the surrounding infrastructure.</p>



<h3 class="wp-block-heading">How fresh can the data realistically be?</h3>



<p class="wp-block-paragraph">Hourly is comfortable and suits nearly all reporting. Every fifteen minutes is achievable with COQL on modest modules. Anything closer to real time means webhooks or notification subscriptions rather than polling, which is a considerably larger commitment for a benefit most dashboards do not use.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">The one thing to remember</h2>



<p class="wp-block-paragraph">An extract that returns a different answer each time it runs is not a data quality problem, it is a read consistency problem, and no amount of downstream validation will fix it. Bound both ends of the window, put the upper bound in the past, paginate by key rather than offset, and advance the watermark only when the data is safely in S3.</p>



<p class="wp-block-paragraph">Get that right and the rest of the pipeline becomes ordinary engineering: land it, convert it, catalogue it, query it. Get it wrong and you will spend months chasing numbers that move every time you look at them.</p>



<h2 class="wp-block-heading">Want this built or reviewed?</h2>



<p class="wp-block-paragraph">Most CRM pipelines I get handed work fine on a quiet module and lose rows on the busy one, which is the hardest version to notice. Work I take on:</p>



<ul class="wp-block-list">
<li>Building a Zoho CRM to S3 pipeline end to end: OAuth, Bulk Read extraction, Parquet conversion, Glue catalog, Athena query layer.</li>
<li>Auditing an existing pipeline for read consistency and telling you whether it is silently dropping records.</li>
<li>Reworking incremental logic into closed windows, keyset pagination and durable watermarks.</li>
<li>Deletion handling and reconciliation jobs so row counts stay equal to the CRM.</li>
<li>Modelling nested CRM data into a schema that does not double-count.</li>
<li>Orchestration and alerting on AWS: EventBridge, Lambda or containers, Step Functions, and monitoring that catches drift early.</li>
</ul>



<p class="wp-block-paragraph">Send me your extract query and how you paginate, and I will tell you whether it can lose rows.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/devops/zoho-crm-to-amazon-s3/">Run It Twice, Get Two Answers: Building an ETL Pipeline From Zoho CRM to Amazon S3</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://john-nessime.com/blog/devops/zoho-crm-to-amazon-s3/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
	</channel>
</rss>
