<?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>PII Redaction | John Nessime</title>
	<atom:link href="https://john-nessime.com/blog/tag/pii-redaction/feed/" rel="self" type="application/rss+xml" />
	<link>https://john-nessime.com/blog/tag/pii-redaction/</link>
	<description>Cloud, DevOps, Data &#38; AI — Built, Tested, Explained</description>
	<lastBuildDate>Thu, 24 Sep 2026 06:00:06 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.1.4</generator>

<image>
	<url>https://john-nessime.com/blog/wp-content/uploads/2026/07/cropped-jn-32x32.png</url>
	<title>PII Redaction | John Nessime</title>
	<link>https://john-nessime.com/blog/tag/pii-redaction/</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>Building a Hotel Data Lake on AWS That Agrees With the Night Audit</title>
		<link>https://john-nessime.com/blog/cloud-computing/hotel-data-lake-aws/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Thu, 24 Sep 2026 06:00:06 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[Data Engineering]]></category>
		<category><![CDATA[Hospitality Technology]]></category>
		<category><![CDATA[Amazon Athena]]></category>
		<category><![CDATA[Amazon S3]]></category>
		<category><![CDATA[Apache Iceberg]]></category>
		<category><![CDATA[AWS Glue]]></category>
		<category><![CDATA[Bitemporal Modeling]]></category>
		<category><![CDATA[Booking Systems]]></category>
		<category><![CDATA[Change Data Capture]]></category>
		<category><![CDATA[Data Lake]]></category>
		<category><![CDATA[Data Modeling]]></category>
		<category><![CDATA[Data Quality]]></category>
		<category><![CDATA[Hotel PMS]]></category>
		<category><![CDATA[Lake Formation]]></category>
		<category><![CDATA[Late-Arriving Data]]></category>
		<category><![CDATA[Medallion Architecture]]></category>
		<category><![CDATA[OPERA Cloud]]></category>
		<category><![CDATA[PCI DSS]]></category>
		<category><![CDATA[PII Redaction]]></category>
		<category><![CDATA[Reconciliation]]></category>
		<category><![CDATA[RevPAR]]></category>
		<category><![CDATA[Table Compaction]]></category>
		<category><![CDATA[Travel Technology]]></category>
		<category><![CDATA[USALI]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=461</guid>

					<description><![CDATA[<p>Hotel source data is mutable in the past, so an append-only pipeline drifts away from the PMS without anyone noticing until month close. A practical guide to room-night grain, bitemporal modeling with Apache Iceberg, PMS ingestion, guest data scope, and file physics at hotel volumes.</p>
<p>The post <a href="https://john-nessime.com/blog/cloud-computing/hotel-data-lake-aws/">Building a Hotel Data Lake on AWS That Agrees With the Night Audit</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 opens the manager&#8217;s flash report on one screen and the shiny new dashboard on the other. The RevPAR figures don&#8217;t match. Not by a rounding error either, by enough that the revenue meeting stops being about revenue and turns into an argument about which system is lying.</p>



<p class="wp-block-paragraph">The dashboard usually isn&#8217;t lying. It&#8217;s answering a slightly different question, and the cause is almost always the same one: the pipeline recorded what the property management system said on the day it read it, and the PMS has since changed its mind. A reservation got shortened. A rate was corrected. A rebate landed against a folio that closed two weeks ago. The lake never went back to look.</p>



<p class="wp-block-paragraph">This post is about building a <strong>hotel data lake on AWS</strong> that survives that. Not a service tour, because the AWS documentation already covers S3, Glue and Athena better than a blog post can. What it covers instead is the handful of modelling and ingestion decisions that determine whether your numbers reconcile with the night audit six months from now, and what each of those decisions costs you.</p>



<h2 class="wp-block-heading">The failure that shows up three weeks late</h2>



<p class="wp-block-paragraph">Most first attempts at hotel analytics are append-only. Pull yesterday&#8217;s reservations, write Parquet under a folder keyed by the day you pulled it, register the partition, done. Yesterday reconciles perfectly. So does the day after. Confidence goes up.</p>



<p class="wp-block-paragraph">The problem is that hotel source data is mutable in the past, and it stays mutable for a long time. Group blocks get released. Stays get extended at the desk. Adjustments and rebates get posted against nights that already closed. Commission corrections come back from an OTA weeks after checkout. Night audit itself is a rewrite operation: it closes the business date and posts room and tax charges for every occupied room, and if the auditor reruns it or corrects a posting, the figures for that date change after you already stored them.</p>



<p class="wp-block-paragraph">An append-only lake captures a diagonal. For each business date you keep the first thing you ever saw about it, and nothing after. Every individual day looks right on the day you check it. The month-to-date total drifts. Nobody notices until close, and by then you have no idea which nights moved or why.</p>



<p class="wp-block-paragraph">That is the whole reason to be careful about grain and table format. Everything else in a hospitality pipeline is ordinary data engineering.</p>



<h2 class="wp-block-heading">Fix the grain before you fix the pipeline</h2>



<p class="wp-block-paragraph">The core hotel metrics are per-night metrics. Occupancy is rooms sold divided by rooms available. ADR is room revenue divided by rooms sold. RevPAR is room revenue divided by rooms available, which is the same as ADR multiplied by occupancy. All three are defined against a single night.</p>



<p class="wp-block-paragraph">What the PMS hands you is reservations. A reservation spans nights, carries one confirmation number, and may have different rates on different nights of the same stay. If you store reservations and try to compute occupancy from them, you end up writing date-range arithmetic into every query, and two analysts will write it differently.</p>



<h3 class="wp-block-heading">Explode to room-nights once, in the pipeline</h3>



<p class="wp-block-paragraph">The fact table that makes everything else easy has one row per room, per night, per reservation. Stay dates, rate code, market segment, source of business, channel, room type, rate amount and the taxes as separate columns. Every downstream metric becomes a filter and a sum instead of a range join.</p>



<p class="wp-block-paragraph">Do the explosion in the transform layer, not in the BI tool. If a range join lives in a Power BI model or a QuickSight dataset, it exists once per report and drifts once per report.</p>



<h3 class="wp-block-heading">The denominator is a decision, not a fact</h3>



<p class="wp-block-paragraph">&#8220;Rooms available&#8221; sounds like a constant. It isn&#8217;t. Rooms out of order for renovation, rooms out of service for a night, house-use rooms and comp rooms all get treated differently by different properties, and the PMS report you&#8217;re reconciling against has already made a choice.</p>



<p class="wp-block-paragraph">Same question on the numerator. Whether no-show revenue counts as room revenue, whether cancellation fees count, whether package components get split out of the room rate, whether ADR is quoted net or gross of tax. Hotel accounting conventions follow the Uniform System of Accounts for the Lodging Industry, and most PMS vendors document how their own reports treat each case. Read that documentation before you write a single SQL definition.</p>



<p class="wp-block-paragraph">Write the definitions down in the repository next to the transform code. When the lake and the PMS disagree later, the first question is always whether it&#8217;s a bug or a definition, and without written definitions you cannot answer that question quickly.</p>



<h2 class="wp-block-heading">Model restatements with two dates, not one</h2>



<p class="wp-block-paragraph">Every row needs a business date, which is the night the revenue belongs to, and an as-of timestamp, which is when you learned it. Two time axes. That&#8217;s what makes the difference between &#8220;the numbers changed&#8221; and &#8220;here is exactly what changed, when, and by how much&#8221;.</p>



<p class="wp-block-paragraph">There are two ways to store that. Keep every version of every row and let the reader pick a version, or keep the current truth in a table you update in place and keep the history separately. The second is easier to query and easier to explain, and it&#8217;s the one I reach for first. It needs a table format that supports updates and deletes, which in practice on AWS means Apache Iceberg.</p>



<p class="wp-block-paragraph">Iceberg gives you three things that matter here: row-level MERGE, snapshots you can query as of a point in time, and schema evolution without rewriting the dataset. Athena, Glue ETL, EMR and Redshift all read Iceberg tables through the Glue Data Catalog, so you&#8217;re not tying the storage to one engine.</p>



<p class="wp-block-paragraph">A curated room-night table in Athena looks roughly like this. Note the partitioning, which I&#8217;ll come back to.</p>



<pre class="wp-block-code"><code>CREATE TABLE curated.room_night (
  property_code      string,
  business_date      date,
  confirmation_id    string,
  room_number        string,
  room_type_code     string,
  rate_code          string,
  market_segment     string,
  source_of_business string,
  room_revenue       decimal(12,2),
  room_tax           decimal(12,2),
  reservation_status string,
  guest_key          string,
  as_of_ts           timestamp,
  source_system      string
)
PARTITIONED BY (property_code, month(business_date))
LOCATION 's3://example-lake-curated/room_night/'
TBLPROPERTIES ('table_type' = 'ICEBERG');</code></pre>



<p class="wp-block-paragraph">Loading is a MERGE from a staging table holding the latest extract. Matched rows get overwritten with the newer version, unmatched rows get inserted, and rows that vanished from the source get marked rather than deleted.</p>



<pre class="wp-block-code"><code>MERGE INTO curated.room_night AS t
USING staging.room_night_batch AS s
  ON  t.property_code   = s.property_code
  AND t.business_date   = s.business_date
  AND t.confirmation_id = s.confirmation_id
  AND t.room_number     = s.room_number
WHEN MATCHED AND s.as_of_ts &gt; t.as_of_ts THEN
  UPDATE SET
    room_revenue       = s.room_revenue,
    room_tax           = s.room_tax,
    rate_code          = s.rate_code,
    reservation_status = s.reservation_status,
    as_of_ts           = s.as_of_ts
WHEN NOT MATCHED THEN
  INSERT (property_code, business_date, confirmation_id, room_number,
          room_revenue, room_tax, rate_code, reservation_status, as_of_ts)
  VALUES (s.property_code, s.business_date, s.confirmation_id, s.room_number,
          s.room_revenue, s.room_tax, s.rate_code, s.reservation_status, s.as_of_ts);</code></pre>



<p class="wp-block-paragraph">The <code>as_of_ts</code> comparison in the MATCHED clause is doing real work. Extracts arrive out of order more often than you&#8217;d like, particularly when a backfill runs alongside the scheduled job, and without that guard a late-arriving older snapshot overwrites newer truth. It&#8217;s a silent corruption, which is the worst kind.</p>



<h3 class="wp-block-heading">Keep a restatement log, not just the current state</h3>



<p class="wp-block-paragraph">Before the MERGE runs, write the delta out to an append-only table: key, field changed, old value, new value, as-of timestamp. It costs almost nothing and it turns &#8220;the March numbers moved&#8221; into a query that returns rows.</p>



<p class="wp-block-paragraph">You can also lean on Iceberg snapshots and time travel for this, and they&#8217;re useful for spot checks. I still prefer an explicit log, because snapshot retention will eventually expire the snapshot you want, and finance will eventually ask about a night that&#8217;s older than your retention window.</p>



<h2 class="wp-block-heading">Landing the sources without building a point-to-point mess</h2>



<p class="wp-block-paragraph">A property runs more systems than people expect. PMS, POS, channel manager, revenue management, spa and golf, parking, loyalty, and whatever the marketing team bought last year. Land each of them raw and immutable first, then transform. The raw zone is your audit trail and your ability to reprocess without going back to a vendor API.</p>



<h3 class="wp-block-heading">The PMS is the hard one</h3>



<p class="wp-block-paragraph">For OPERA Cloud, the Oracle Hospitality Integration Platform is the supported route. It exposes REST APIs over OAuth, and registration is self-service rather than going through the older partner validation process. Two things to plan for: the API surface is large and reservation, financial and configuration data live in different areas, and access depends on the property&#8217;s licensing, so confirm what the property actually has before you scope the work.</p>



<p class="wp-block-paragraph">Legacy on-premise OPERA is a different world. Integration there goes through the older interface stack and the validation programme, and scheduled report exports to a file drop are frequently the pragmatic answer. Cloud-native systems like Cloudbeds and Mews expose REST APIs directly and are far less work.</p>



<p class="wp-block-paragraph">Reading the PMS database directly, where you can even reach it, is tempting and usually a bad idea. Schema changes arrive with vendor upgrades and nothing tells you in advance, and depending on the contract it can put support out of scope. Check the agreement before you build anything on top of direct database access.</p>



<h3 class="wp-block-heading">The relay box, if you need one</h3>



<p class="wp-block-paragraph">Properties with on-premise systems usually need something that picks up scheduled exports and pushes them to S3. Keep it boring: a small always-on Linux host, a scheduled job, checksum on write, and an SNS or CloudWatch alarm when a file doesn&#8217;t turn up. A cheap VPS from somewhere like Contabo or InterServer does this fine, and it is much easier to hand over at the end of an engagement than a machine sitting under the front desk.</p>



<p class="wp-block-paragraph">For reaching that host without opening inbound ports on a hotel network, an outbound-only tunnel such as Cloudflare Tunnel is a clean fit. If staff are hitting admin interfaces from guest or public Wi-Fi, a consumer VPN client like NordVPN or Surfshark protects that transport, though it is not a substitute for a real network path into the property.</p>



<h3 class="wp-block-heading">Time zones will bite you</h3>



<p class="wp-block-paragraph">The hotel business date is not a calendar day and it is not UTC. It&#8217;s a property-local concept that rolls at night audit, which might be at 2am or 4am depending on the property, and it can be held open when the audit runs late.</p>



<p class="wp-block-paragraph">Store the source system&#8217;s own business date as the partition key. Store event timestamps in UTC alongside it. Never derive the business date by truncating a UTC timestamp, because for a group with properties in several time zones that quietly shifts revenue between nights, and the error is small enough to look like noise.</p>



<h2 class="wp-block-heading">Keep card data out and plan for erasure on day one</h2>



<p class="wp-block-paragraph">Guest records are personal data and hotels hold a lot of it: names, addresses, passport and ID details in some jurisdictions, loyalty identifiers, stay history. Payment card data sits under a separate regime again.</p>



<p class="wp-block-paragraph">The single highest-value decision is negative. Do not bring cardholder data into the analytics platform at all. Nothing in occupancy, ADR or segment analysis needs a card number, and pulling one in drags the whole lake, its query engines and everyone with access into PCI DSS scope. Take the payment token or a masked reference from the PMS and stop there.</p>



<p class="wp-block-paragraph">For guest identity, pseudonymise at ingestion. Keep a surrogate guest key in the analytics tables and hold the mapping to real identity in one small, separately governed place with its own access controls. Amazon Macie can scan the raw zone for personal data that shouldn&#8217;t be there, which is useful precisely because someone will eventually land a CSV export nobody reviewed.</p>



<p class="wp-block-paragraph">Erasure needs thinking about before you have to do it. Iceberg makes row-level deletes straightforward, but a deleted row is still present in older snapshots, and time travel will happily return it. Deletion isn&#8217;t complete until snapshot expiration has run past the snapshots containing that row and the underlying files have been removed. Set retention deliberately, know what your window is, and be able to state it. Crypto-shredding, where the identity mapping is encrypted with a per-subject key you can destroy, is the cleaner design if the requirement is strict.</p>



<p class="wp-block-paragraph">The same discipline applies to workstations. Analysts pull extracts, extracts contain guest names, and laptops get replaced. A secure erasure tool such as O&amp;O SafeErase covers that end of it, and it belongs in the same policy as the lake.</p>



<p class="wp-block-paragraph">For access control, Lake Formation is the right layer. Define column and row filters once and they&#8217;re enforced across Athena, EMR and Redshift rather than reimplemented per engine. A group operator can then give each property&#8217;s management team row access to its own data from the same tables, which is far less work than building a schema per property.</p>



<h2 class="wp-block-heading">File physics at hotel scale</h2>



<p class="wp-block-paragraph">Hotel data is small. A single property produces a few hundred room-nights a day. Even a large group is nowhere near the volumes that data lake tutorials assume, and that changes the advice in one specific direction: your problem is too many tiny files, not too much data.</p>



<p class="wp-block-paragraph">Two habits create it. Partitioning by day when a property generates kilobytes per day, and polling the PMS every fifteen minutes so each run writes another sliver. Both feel careful and both make queries slower and more expensive, because the engine spends its time opening files rather than reading them.</p>



<ul class="wp-block-list">
<li>Partition curated tables by property and month, not by day. Iceberg prunes from its own metadata, so you get file-level skipping without the partition explosion.</li>

<li>Batch the writes. Buffer frequent polls and MERGE on a schedule that matches how the numbers are actually consumed, which for most operational reporting means hourly at most.</li>

<li>Run compaction. The Glue Data Catalog offers managed table optimizers for Iceberg tables covering compaction, snapshot retention and orphan file deletion, configurable from the console, CLI or API. Check the current format and engine support against the AWS docs before you rely on it.</li>

<li>Or drive it from Athena directly with <code>OPTIMIZE</code> to rewrite data files and <code>VACUUM</code> to expire snapshots and remove orphans.</li>
</ul>



<p class="wp-block-paragraph">For the raw zone, where tables are usually plain Hive-style Parquet rather than Iceberg, Athena partition projection is worth knowing about. It computes partition values from a configured pattern instead of listing them from the catalog, which removes a metadata round trip on tables with many partitions. It does not apply to Iceberg tables, which handle pruning through their own manifests.</p>



<p class="wp-block-paragraph">Athena bills on data scanned and Glue on job runtime, so cost tracks file layout and query shape rather than how much data you own. That&#8217;s a good thing at this scale, but it does mean a single badly written dashboard query on a schedule can cost more than the entire storage bill. Tools like Vantage or CloudZero make that visible per workload if the AWS cost console isn&#8217;t giving you enough granularity.</p>



<h2 class="wp-block-heading">Troubleshooting a hotel data lake on AWS</h2>



<p class="wp-block-paragraph"><strong>The lake and the PMS report disagree by a small, consistent amount.</strong> Almost always a definition, not a bug. Compare the room count first: if rooms sold matches and revenue doesn&#8217;t, you&#8217;re including or excluding tax, packages or fees differently. If rooms sold doesn&#8217;t match, check house-use and comp rooms.</p>



<p class="wp-block-paragraph"><strong>Yesterday reconciles, last month doesn&#8217;t.</strong> The append-only failure. Query your restatement log for that period. If you don&#8217;t have one, compare the current curated table against an older Iceberg snapshot for the same business dates.</p>



<p class="wp-block-paragraph"><strong>Numbers went backwards after a backfill.</strong> An older extract overwrote a newer one. Check that the as-of guard is present in the MERGE and that the staging table is deduplicated to one row per key before the merge runs, keeping the highest as-of value.</p>



<p class="wp-block-paragraph"><strong>Occupancy exceeds one hundred percent.</strong> Usually duplicate room-nights from a reservation that was modified into a new record while the old one stayed active, or a room moved mid-stay and counted in both rooms for the same night. Add a uniqueness check on property, business date and room number to the pipeline and fail the load rather than publishing it.</p>



<p class="wp-block-paragraph"><strong>Queries got slow and nothing changed.</strong> Look at file counts before anything else. Compaction that stopped running, or a new high-frequency job appending small files, explains most of these.</p>



<p class="wp-block-paragraph"><strong>A daily load silently produced nothing.</strong> This is the one that hurts, because an empty successful run looks like a healthy pipeline. Alert on the absence of expected rows per property per business date, not just on job failure. A deadman check in CloudWatch, or in Grafana Cloud if you already run dashboards there, catches it the same morning instead of at month end.</p>



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



<ul class="wp-block-list">
<li>Storing reservations instead of room-nights, then rebuilding the date logic in every report.</li>

<li>Treating the ingestion date as the business date. They diverge the first time a load fails and gets rerun the next morning.</li>

<li>Building the reporting layer before agreeing the metric definitions with the finance and revenue teams.</li>

<li>Pulling the full guest profile because the API returns it, rather than the fields the analysis needs.</li>

<li>Skipping the raw zone and transforming on ingest, which means a logic bug is unrecoverable without re-extracting from the vendor.</li>

<li>Partitioning by day out of habit, then wondering why a small dataset queries slowly.</li>

<li>Relying on time travel as the audit trail, and discovering retention expired the snapshot finance asked about.</li>
</ul>



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



<ul class="wp-block-list">
<li>One row per room, per night, per reservation. Everything else derives from it.</li>

<li>Business date and as-of timestamp on every fact row, always.</li>

<li>Raw zone immutable, curated zone merged, consumption zone shaped for the tool that reads it.</li>

<li>Metric definitions in version control, next to the SQL that implements them.</li>

<li>A daily reconciliation query against one PMS report, checked automatically, with a tolerance and an alert.</li>

<li>Pseudonymise guest identity at ingestion and keep the mapping somewhere separate.</li>

<li>Compaction and snapshot retention configured before the table grows, not after someone complains.</li>
</ul>



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



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">Do I need Iceberg, or is plain Parquet enough?</h3>



<p class="wp-block-paragraph">Plain Parquet is fine for anything genuinely append-only, such as raw extracts and event logs. It stops being fine the moment you need to correct history, because updating a row means rewriting whole partitions and coordinating that yourself. Since correcting history is the defining characteristic of hotel data, the curated layer wants a table format with row-level updates.</p>



<h3 class="wp-block-heading">Should the data lake match the PMS exactly?</h3>



<p class="wp-block-paragraph">It should match one named PMS report, on one named metric, within a tolerance you&#8217;ve agreed. Trying to match every report is a trap, because vendor reports use different denominators and different treatment of taxes and fees, and several of them disagree with each other. Pick the report finance already trusts and reconcile to that one.</p>



<h3 class="wp-block-heading">How often should the pipeline pull from the PMS?</h3>



<p class="wp-block-paragraph">Match the decision it feeds. Financial reporting is a daily cycle anchored on night audit and needs nothing faster. Operational views like arrivals and room status benefit from intraday refresh, but that&#8217;s a different table with a different retention policy, not a reason to run the whole lake hourly.</p>



<h3 class="wp-block-heading">Can guest names go in the data lake?</h3>



<p class="wp-block-paragraph">Sometimes, with a lawful basis, retention limits and access controls. But most of the analysis people actually want, occupancy, rate, segment, channel mix, pace, works perfectly on a pseudonymous key. Start without names and add them back only for a use case that genuinely requires them. Card data is a different matter and the answer there is simply no.</p>



<h3 class="wp-block-heading">Athena or Redshift for the reporting layer?</h3>



<p class="wp-block-paragraph">At single-property and small-group volumes, Athena over Iceberg is usually enough, and there&#8217;s no cluster to size or keep warm. Redshift earns its place when you have high-concurrency BI users, heavy joins across many domains, or an existing warehouse investment. Either way, keep the storage open so the choice stays reversible.</p>



<h3 class="wp-block-heading">How do I handle a group with many properties?</h3>



<p class="wp-block-paragraph">One set of tables with property code as the leading partition key, and Lake Formation row filters for per-property access. Separate schemas per property look tidy at three properties and become unmanageable at thirty, because every schema change and every metric fix has to be applied thirty times.</p>



<h3 class="wp-block-heading">Where does benchmarking data fit?</h3>



<p class="wp-block-paragraph">Land it in the same lake as a separate domain, at whatever grain the provider supplies, usually a market aggregate rather than a room-night. Keep it in its own tables and join at report time. Mixing external benchmark figures into your own fact table is how index numbers end up in a revenue total.</p>



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



<p class="wp-block-paragraph">A hotel data lake on AWS is not hard because of the volume. It&#8217;s hard because the past keeps changing, and an architecture that assumes otherwise fails quietly and slowly rather than loudly and fast.</p>



<p class="wp-block-paragraph">Get the room-night grain right, carry a business date and an as-of timestamp on every row, merge instead of appending, and write down what each metric means before anyone builds a dashboard on it. The S3, Glue, Iceberg and Athena assembly is the easy part, and it&#8217;s well documented. Reconciling with the night audit six months later is the part that decides whether anyone trusts the thing.</p>



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



<h2 class="wp-block-heading">Working on this with me</h2>



<p class="wp-block-paragraph">Most of the hospitality data work I get asked about is one of a small number of shapes. Here&#8217;s where I&#8217;d be useful:</p>



<ul class="wp-block-list">
<li>Designing the room-night model and the bitemporal load, including the MERGE logic and the restatement log, so your numbers reconcile with the night audit and you can show why they moved.</li>

<li>Getting data out of the PMS reliably, whether that&#8217;s OHIP against OPERA Cloud, a REST API on a cloud-native system, or a scheduled export relay for an on-premise property.</li>

<li>Auditing an existing lake that has quietly drifted, finding which business dates restated and which metric definitions diverged from the reports finance uses.</li>

<li>Cutting Athena and Glue spend on a small dataset that behaves like a big one, usually through partition layout, compaction and killing scheduled queries nobody reads.</li>

<li>Setting up Lake Formation row and column controls for a multi-property group so each property sees its own data from shared tables.</li>

<li>Building the boring safety net: freshness and row-count checks per property per business date, deadman alerts, and a daily automated reconciliation against a named PMS report.</li>
</ul>



<p class="wp-block-paragraph">If you&#8217;ve got a discrepancy you can&#8217;t explain, send me the two figures and the query behind one of them, and I&#8217;ll tell you which side is wrong.</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/cloud-computing/hotel-data-lake-aws/">Building a Hotel Data Lake on AWS That Agrees With the Night Audit</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Amazon Macie PII Detection: The Buckets It Never Opened</title>
		<link>https://john-nessime.com/blog/cloud-computing/amazon-macie-pii-detection/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sun, 06 Sep 2026 06:00:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[Compliance]]></category>
		<category><![CDATA[Data Engineering]]></category>
		<category><![CDATA[Amazon Data Firehose]]></category>
		<category><![CDATA[Amazon Macie]]></category>
		<category><![CDATA[Amazon S3]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[AWS KMS]]></category>
		<category><![CDATA[AWS Organizations]]></category>
		<category><![CDATA[Cloud Security]]></category>
		<category><![CDATA[Cost Optimization]]></category>
		<category><![CDATA[Data Classification]]></category>
		<category><![CDATA[Data Governance]]></category>
		<category><![CDATA[Data Lake]]></category>
		<category><![CDATA[Encryption]]></category>
		<category><![CDATA[EventBridge]]></category>
		<category><![CDATA[PII Redaction]]></category>
		<category><![CDATA[S3 Lifecycle Rules]]></category>
		<category><![CDATA[Security Hub]]></category>
		<category><![CDATA[Sensitive Data Discovery]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=405</guid>

					<description><![CDATA[<p>A Macie bucket labeled "Not sensitive" often just means Macie never read it. Extensionless objects, unsupported storage classes, unreachable KMS keys and quota truncation all produce silence that looks identical to a clean result. Here's how to measure coverage, fix the four gaps, tune identifiers, and keep the bill honest.</p>
<p>The post <a href="https://john-nessime.com/blog/cloud-computing/amazon-macie-pii-detection/">Amazon Macie PII Detection: The Buckets It Never Opened</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 email came from legal, not from security. A customer had exercised a data access request, someone pulled the export by hand, and it contained email addresses and phone numbers sitting in a bucket Macie had labeled <em>Not sensitive</em>. Nobody had done anything wrong. The console was telling the truth as it understood it. It just didn&#8217;t understand very much about that bucket.</p>



<p class="wp-block-paragraph">That is the failure mode worth internalizing before anything else: Amazon Macie PII detection reports on what it managed to read, not on what is there. Objects it never opened produce no findings, and no findings looks identical to a clean result. The gap between &#8220;we scanned this&#8221; and &#8220;we found nothing&#8221; is where most Macie deployments quietly fail their first audit.</p>



<p class="wp-block-paragraph">This post covers where that gap comes from and how to close it: the eligibility chain every object goes through, the four distinct reasons an object gets skipped, how to tune identifiers so the findings are worth reading, what actually drives the bill, and how to route results somewhere a human will act on them.</p>



<h2 class="wp-block-heading">The eligibility chain behind Amazon Macie PII detection</h2>



<p class="wp-block-paragraph">Every object passes the same gates before a byte gets inspected. The order matters, because each gate fails differently and needs a different fix.</p>



<ol class="wp-block-list">
<li>Is it in a general purpose S3 bucket? Directory buckets are out of scope entirely.</li>



<li>Is the storage class supported? Standard, Standard-IA, One Zone-IA, Intelligent-Tiering, Glacier Instant Retrieval and Reduced Redundancy are in. Glacier Deep Archive and S3 Express One Zone are not.</li>



<li>Does the object key carry a recognized file extension? This is a string check on the name, not a content sniff.</li>



<li>Can Macie retrieve and decrypt it, given the bucket policy, object ACL and encryption key?</li>



<li>Does the content parse, and stay inside the per-file quotas?</li>



<li>Do the active data identifiers match anything in it?</li>
</ol>



<p class="wp-block-paragraph">Only the last gate produces a finding. Everything before it produces silence. Macie does record why each object was skipped, but that lives in coverage data and object samples, not on the findings page most people look at.</p>



<p class="wp-block-paragraph">One detail explains a lot of confusion: when you first enable automated sensitive data discovery, every bucket gets a sensitivity score of 50 and the label <em>Not yet analyzed</em>. A bucket whose permissions block Macie stays there permanently. It never turns red. It sits mid-list, looking unremarkable next to buckets that genuinely were analyzed.</p>



<h2 class="wp-block-heading">Gap one: objects Macie never opened</h2>



<p class="wp-block-paragraph">Macie calls these <em>unclassifiable</em>. They fail the storage class or extension check, so no retrieval is attempted. This is the largest source of false confidence I&#8217;ve seen, and the cheapest to fix.</p>



<h3 class="wp-block-heading">Missing file extensions</h3>



<p class="wp-block-paragraph">Classifiability comes from the file name extension. A file full of customer records named <code>part-00003-a4f9</code> is invisible. The same bytes named <code>part-00003-a4f9.json</code> get inspected and produce findings.</p>



<p class="wp-block-paragraph">This bites hardest on streaming ingestion. Amazon Data Firehose writes to S3 without appending an extension unless compression, format conversion, or the explicit file extension setting adds one. An uncompressed JSON stream lands as a tree of extensionless objects. Athena and Glue read them fine, because they infer format from the table definition. Macie skips every one.</p>



<p class="wp-block-paragraph">Fix it upstream: set the S3 file extension on the delivery configuration, or enable GZIP compression or Parquet conversion, both of which append a recognized extension. If you can&#8217;t change the producer, the fallback is a copy-and-rename step into a scan prefix, which costs you storage plus a pipeline to maintain. Fixing the producer is almost always cheaper.</p>



<h3 class="wp-block-heading">Unsupported storage classes</h3>



<p class="wp-block-paragraph">A lifecycle rule transitioning old exports to Glacier Deep Archive quietly removes them from scope. That may be the right storage decision, but it needs to be a conscious one, because archived customer data is still customer data when a regulator asks.</p>



<p class="wp-block-paragraph">Macie exposes these counts per bucket, split by cause. This is the first query I run against a new account:</p>



<pre class="wp-block-code"><code># Buckets where Macie can't classify objects, broken down by cause
aws macie2 describe-buckets 
  --query 'buckets[?unclassifiableObjectCount.total &gt; `0`].{
      bucket: bucketName,
      classifiable: classifiableObjectCount,
      skipped_total: unclassifiableObjectCount.total,
      skipped_extension: unclassifiableObjectCount.fileType,
      skipped_storage_class: unclassifiableObjectCount.storageClass
    }' 
  --output table</code></pre>



<p class="wp-block-paragraph">Read the ratio, not the raw number. Forty thousand classifiable objects and twelve skipped is fine. Three classifiable and four hundred thousand skipped is a reporting artifact pretending to be a scan result, and that bucket&#8217;s sensitivity score means nothing.</p>



<h2 class="wp-block-heading">Gap two: objects it opened and couldn&#8217;t read</h2>



<p class="wp-block-paragraph">These are classification errors rather than eligibility failures. Macie selected the object, tried to fetch it, and was refused. Three causes, and they need three different people to fix them.</p>



<ul class="wp-block-list">
<li><strong>Customer-provided keys (SSE-C).</strong> Macie cannot supply the key material, so it cannot retrieve the object. No permissions fix exists. Re-encrypt with S3 managed or KMS keys if you want coverage.</li>



<li><strong>KMS key policy.</strong> For customer managed keys, the key policy must allow the Macie service-linked role, <code>AWSServiceRoleForAmazonMacie</code>, to decrypt. Cross-account buckets need the key owner to grant it, not the bucket owner. AWS publishes a permission analyzer script in the <code>aws-samples/amazon-macie-scripts</code> repository that enumerates every key Macie needs and generates the CLI commands to fix them.</li>



<li><strong>Restrictive bucket policies.</strong> An explicit <code>Deny</code> conditioned on source IP or VPC endpoint blocks Macie along with everything else. The working pattern excludes the service-linked role ARN from the deny using the <code>aws:PrincipalArn</code> condition key.</li>
</ul>



<p class="wp-block-paragraph">To see which objects Macie actually touched in a bucket, pull the object samples. It&#8217;s the closest thing to a scan log you get:</p>



<pre class="wp-block-code"><code># Objects automated discovery selected, with per-object status
aws macie2 list-resource-profile-artifacts 
  --resource-arn arn:aws:s3:::your-bucket-name</code></pre>



<p class="wp-block-paragraph">Anything with a status of <code>SKIPPED</code> is a lead. Take the key, check its encryption settings in S3, and you usually have your answer inside a minute.</p>



<h2 class="wp-block-heading">Gap three: it read the file and nothing matched</h2>



<p class="wp-block-paragraph">Macie ships managed data identifiers covering common PII, financial data and credentials across many countries. Automated discovery uses a recommended subset by default rather than all of them. Sensible, but it is a default, and it will miss things that matter to you.</p>



<p class="wp-block-paragraph"><strong>Add the managed identifiers for your actual jurisdictions.</strong> If you hold records for customers in a country whose national ID identifier isn&#8217;t in the recommended set, you&#8217;re scanning for the wrong things with perfect efficiency. Pull the current list with <code>aws macie2 list-managed-data-identifiers</code> and compare it against where your customers live.</p>



<p class="wp-block-paragraph"><strong>Write custom identifiers for what only you know is sensitive.</strong> Internal account numbers, case references, employee IDs. A custom identifier is a regex plus optional keywords and a proximity rule, and the keywords are what save you. A bare <code>[0-9]{8}</code> matches timestamps, order totals and row counts, and you drown. The same regex with keywords and a match distance only fires when the number sits near a word that gives it meaning.</p>



<p class="wp-block-paragraph">Test the pattern before it goes near a job. This runs the criteria against sample text and returns match counts without creating anything:</p>



<pre class="wp-block-code"><code># Dry-run a custom identifier before creating it
aws macie2 test-custom-data-identifier 
  --regex 'ACC-[0-9]{4}-[0-9]{4}' 
  --keywords account customer acct 
  --maximum-match-distance 30 
  --sample-text 'customer account ACC-4821-9930 updated; total 1234-5678'</code></pre>



<p class="wp-block-paragraph">Run it against real samples, including the awkward ones, then against a file you know is clean and confirm zero. A pattern you only tested on positives is a pattern you haven&#8217;t tested.</p>



<p class="wp-block-paragraph">The mirror image is allow lists, which tell Macie to ignore specific text or patterns: your support inbox address, your published press contact, the seeded test records in everyone&#8217;s staging fixtures. Without them the same non-issues appear in every scan until the team stops reading findings. Allow lists accept predefined text or regex, and the per-account and per-job quotas are tight enough that you should curate rather than dump.</p>



<h2 class="wp-block-heading">Gap four: quota truncation inside large files</h2>



<p class="wp-block-paragraph">Subtle, because the object <em>is</em> analyzed. Just not completely, and a partial result reports like a full one.</p>



<ul class="wp-block-list">
<li>Per-file size quotas differ by format. Exceed the quota for a type and Macie analyzes none of that file, not part of it.</li>



<li>Archives have separate ceilings for nesting depth, extracted bytes and file count. If the metadata shows a breach up front, nothing is extracted. Cross a limit partway through and Macie stops, reporting only what it processed.</li>



<li>JSON and JSON Lines have a nested depth limit. Past it, the file is not analyzed at all.</li>



<li>Full names and mailing addresses cap out per file. After the cap Macie stops counting and stops reporting locations for that type, so the occurrence count is a floor, never a total.</li>
</ul>



<p class="wp-block-paragraph">Findings carry a status reason naming the limit that was hit, with values like <code>FILE_EXCEEDS_SIZE_LIMIT</code>, <code>ARCHIVE_NESTING_LEVEL_OVER_LIMIT</code> and <code>JSON_NESTING_LEVEL_OVER_LIMIT</code>. Filter on those before declaring a data lake of large compressed exports clean. Check current quota values in the Macie documentation rather than memorizing them, since they differ per format.</p>



<h2 class="wp-block-heading">Automated discovery or a discovery job?</h2>



<p class="wp-block-paragraph"><strong>Automated sensitive data discovery</strong> evaluates your inventory daily and samples representative objects across as many buckets as it can, grouping by metadata like prefix, extension and last-modified date. It is breadth-first and it does not re-analyze unchanged objects. Its job is to tell you which buckets deserve attention. It is not designed to prove a bucket is clean, and reading it that way is the root of most Macie misunderstandings.</p>



<p class="wp-block-paragraph"><strong>Sensitive data discovery jobs</strong> are the depth tool. You choose the buckets, scope by prefix, extension, size or object tag, choose the identifiers, and run once or on a schedule. This is what you point at the bucket automated discovery just flagged.</p>



<pre class="wp-block-code"><code># One-time job over a single prefix, recommended identifiers, 30% sampling
aws macie2 create-classification-job 
  --job-type ONE_TIME 
  --name "exports-prefix-review" 
  --managed-data-identifier-selector RECOMMENDED 
  --sampling-percentage 30 
  --s3-job-definition '{
    "bucketDefinitions": [
      {"accountId": "111122223333", "buckets": ["your-bucket-name"]}
    ],
    "scoping": {
      "includes": {
        "and": [
          {"simpleScopeTerm": {
             "comparator": "STARTS_WITH",
             "key": "OBJECT_KEY",
             "values": ["exports/"]
          }}
        ]
      }
    }
  }'</code></pre>



<p class="wp-block-paragraph">One detail about <code>--sampling-percentage</code> that trips people up: it selects a random percentage of eligible <em>objects</em> and then analyzes each selected object completely. It does not read a fraction of each file. For a bucket where every object comes from one pipeline, 20 or 30 percent tells you what you need. For heterogeneous uploads, sampling is a coin flip and you want the full pass.</p>



<h2 class="wp-block-heading">What actually drives the bill</h2>



<p class="wp-block-paragraph">Macie bills on three dimensions, and the one that surprises people is not the one they budget for.</p>



<ul class="wp-block-list">
<li><strong>Bucket evaluation.</strong> Per general purpose bucket monitored, prorated daily, up to the account ceiling. Predictable, driven by bucket count rather than data volume.</li>



<li><strong>Object monitoring.</strong> Per object tracked while automated discovery is enabled. This is inventory bookkeeping, not content reading, and it runs whether or not anything gets scanned that day. On an account with a log archive holding hundreds of millions of tiny objects, it can dwarf the other two.</li>



<li><strong>Data inspected.</strong> Per GB actually analyzed, covering both automated discovery and jobs.</li>
</ul>



<p class="wp-block-paragraph">So excluding buckets from automated discovery is a genuine cost lever, not just noise reduction. Log archives, CloudTrail destinations, build artifact stores and backup targets are usually safe exclusions. Exclude for the right reason though: a backup bucket holding database dumps of your customer table is exactly what you want scanned, however boring its name sounds.</p>



<p class="wp-block-paragraph">The three dimensions appear as distinct usage types in the Cost and Usage Report, so they split cleanly. A FinOps platform like Vantage or CloudZero, or just a Cost Explorer view grouped by usage type, tells you within a day whether object monitoring or inspection is driving the number. That changes the fix entirely: object monitoring is solved by scoping, inspection by sampling and scheduling.</p>



<h2 class="wp-block-heading">Getting findings somewhere a human sees them</h2>



<p class="wp-block-paragraph">Findings that only exist in the Macie console get read during onboarding week and never again. Two things are worth wiring up on day one.</p>



<p class="wp-block-paragraph"><strong>Publish findings outward.</strong> Macie emits findings to EventBridge and integrates with Security Hub. EventBridge earns its keep because you can filter on severity and finding type and route only what matters, whether that&#8217;s a Slack channel, a ticket, or a Lambda that tags the bucket. If you already centralize alerts in Grafana Cloud or similar, that path keeps sensitive data findings next to everything else on call sees.</p>



<p class="wp-block-paragraph"><strong>Configure the discovery results repository.</strong> Findings tell you what was found and where. The detailed discovery results, including analysis logs for objects where nothing was found, need an S3 bucket and a KMS key configured to persist. Nothing is retained long term until you set this up, and &#8220;we scanned it and found nothing&#8221; is exactly the record an auditor asks for.</p>



<p class="wp-block-paragraph">One operational note on the reveal feature, which retrieves sample occurrences so a human can confirm a finding is real. It needs a customer managed KMS key and it&#8217;s genuinely useful for triage. It also means someone is now looking at live customer PII, which belongs in your access model and audit trail. Same goes if an engineer downloads an object to investigate locally: that copy is unmanaged PII on a laptop and needs a secure deletion step, whether that&#8217;s a wipe tool from a vendor like O&amp;O Software or a documented and enforced process. Findings triage has a habit of creating the exposure it was meant to prevent.</p>



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



<h2 class="wp-block-heading">Troubleshooting a bucket that reports nothing</h2>



<p class="wp-block-paragraph">When Amazon Macie PII detection reports nothing for a bucket you have doubts about, work down this in order. Each step rules out one gate, and stopping at the first hit saves you the rest.</p>



<ol class="wp-block-list">
<li><strong>Check the sensitivity label.</strong> <em>Not yet analyzed</em> at score 50 means nothing was analyzed. Do not read that as clean.</li>



<li><strong>Check the coverage page.</strong> Macie names the issue directly: access denied, invalid encryption, invalid KMS key, permission denied, or unclassifiable.</li>



<li><strong>Compare classifiable and unclassifiable counts</strong> with the <code>describe-buckets</code> query above.</li>



<li><strong>List a few object keys.</strong> Run <code>aws s3 ls</code> against a prefix and look for extensions. Extensionless keys explain the whole thing.</li>



<li><strong>Check the storage class.</strong> A lifecycle rule may have moved everything somewhere Macie doesn&#8217;t read.</li>



<li><strong>Check encryption.</strong> SSE-C is unfixable without re-encryption. A customer managed KMS key is fixable with a key policy change.</li>



<li><strong>Check object samples</strong> with <code>list-resource-profile-artifacts</code> to see exactly what was selected and skipped.</li>



<li><strong>Only now question the identifiers.</strong> Run a targeted job with a custom identifier for the data you expect and see whether it fires.</li>
</ol>



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



<ul class="wp-block-list">
<li>Treating a low sensitivity score as evidence of no PII. It reflects what was found <em>and</em> how much was analyzed, so low coverage produces a low score.</li>



<li>Enabling Macie in one region and assuming account-wide coverage. Settings and results are per region, every time.</li>



<li>Running default identifiers in a business operating outside the jurisdictions the recommended set covers.</li>



<li>Building custom identifiers from regex alone, then abandoning Macie over the false positive volume.</li>



<li>Skipping the discovery results repository, then having no evidence trail when someone asks what was scanned and when.</li>



<li>Excluding buckets by name pattern without checking contents. Backup and export buckets are frequently the highest-risk ones you own.</li>
</ul>



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



<ul class="wp-block-list">
<li>Measure coverage before findings. A coverage report is the first deliverable of a Macie rollout, not an afterthought.</li>



<li>Fix extensions at the producer rather than building a rename pipeline downstream.</li>



<li>Run the KMS permission analyzer script once per account, then again whenever a new customer managed key appears.</li>



<li>Use automated discovery for breadth and targeted jobs for depth, letting the first choose targets for the second.</li>



<li>Test every custom identifier against both a positive and a negative sample before it reaches a job.</li>



<li>Curate allow lists early. The cost of a noisy first month is a team that stops reading findings.</li>



<li>Re-check coverage after any change to bucket policies, KMS keys, lifecycle rules or ingestion pipelines. All four silently remove data from scope.</li>
</ul>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">Can Amazon Macie scan anything other than S3?</h3>



<p class="wp-block-paragraph">No. Macie analyzes objects in S3 general purpose buckets only. The documented workaround is bringing data to it: export RDS or Aurora snapshots to S3 in Parquet, or export a DynamoDB table to S3, then run a discovery job against the export. That works, but the export is now a second copy of your sensitive data with its own encryption and deletion requirements.</p>



<h3 class="wp-block-heading">Does Macie scan new objects as they land?</h3>



<p class="wp-block-paragraph">Not per object. Automated discovery evaluates inventory on a daily cycle and prioritizes objects that are new or recently changed, so fresh data moves up the queue rather than triggering an immediate scan. If you need scanning tied to arrival, drive a discovery job from S3 event notifications through EventBridge and accept that you own that orchestration.</p>



<h3 class="wp-block-heading">Why does a bucket I know has PII show a low sensitivity score?</h3>



<p class="wp-block-paragraph">Almost always coverage rather than detection. Check whether the objects are classifiable, whether Macie can decrypt them, and whether the label reads <em>Not yet analyzed</em>. If Macie genuinely read them and found nothing, the next suspect is the identifier set, and a targeted job with a custom identifier will tell you in one run.</p>



<h3 class="wp-block-heading">Do I need both automated discovery and discovery jobs?</h3>



<p class="wp-block-paragraph">For most teams, yes. Automated discovery gives you the map at predictable cost. Jobs give you proof for a specific bucket at a specific time, which is what compliance evidence actually requires. Running only jobs means you never discover the bucket nobody told you about.</p>



<h3 class="wp-block-heading">How do I cut false positives without missing real PII?</h3>



<p class="wp-block-paragraph">In order of preference: add keywords and a proximity rule so matches need context; use allow lists for specific known-benign values like your published contact details and test fixtures; and only then remove managed identifiers that are consistently wrong for your data. Removing identifiers is the bluntest option and the easiest to regret, so leave it last.</p>



<h3 class="wp-block-heading">Does Macie work across accounts in an organization?</h3>



<p class="wp-block-paragraph">Yes, through AWS Organizations with a delegated administrator. Design around this: the administrator&#8217;s automated discovery settings apply to member accounts, members can&#8217;t change them, and members see coverage and sensitivity data for their own buckets but not the sensitive data findings themselves. That shapes who can actually remediate what.</p>



<h2 class="wp-block-heading">The one thing worth remembering</h2>



<p class="wp-block-paragraph">Amazon Macie PII detection is only as good as its coverage, and coverage fails silently by design. An object with the wrong extension, the wrong storage class, or an unreachable encryption key produces no finding, and no finding renders identically to a clean result. Before you hand a Macie report to anyone who will make a decision from it, put the coverage numbers next to it: how many objects were classifiable, how many were analyzed, how many were skipped and why.</p>



<p class="wp-block-paragraph">Do that once and the rest of the service becomes straightforward. Skip it and you&#8217;re maintaining an expensive dashboard reporting on whichever subset of your data happened to be readable.</p>



<h2 class="wp-block-heading">Need help getting Macie to actually see your data?</h2>



<p class="wp-block-paragraph">Most of the work in a Macie rollout isn&#8217;t turning it on. It&#8217;s the unglamorous part: proving what got scanned, fixing the reasons things didn&#8217;t, and making the output land somewhere a human acts on it. That&#8217;s the part I help with.</p>



<ul class="wp-block-list">
<li>Coverage audits across your S3 estate, with per-bucket classifiable, analyzed and skipped counts and the specific cause of each gap</li>



<li>Fixing those causes: KMS key policies, bucket policies blocking the service-linked role, lifecycle rules archiving data out of scope, pipelines writing extensionless objects</li>



<li>Custom data identifiers and allow lists tuned against your real data and tested on positive and negative samples first</li>



<li>Cost shaping: working out whether object monitoring or data inspection drives your bill, and scoping discovery so the number is defensible</li>



<li>Findings pipelines through EventBridge or Security Hub into the alerting and ticketing you already run, with severity filtering that keeps signal above noise</li>



<li>Multi-account setup under AWS Organizations, including the delegated administrator model and who can see and remediate what</li>
</ul>



<p class="wp-block-paragraph">If you want a second opinion, send me the output of the <code>describe-buckets</code> query above, or a screenshot of your coverage page, and I&#8217;ll tell you what&#8217;s actually being scanned.</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/cloud-computing/amazon-macie-pii-detection/">Amazon Macie PII Detection: The Buckets It Never Opened</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>CV Parsing at Scale on AWS: Textract, Bedrock, and the Bias You Have to Document</title>
		<link>https://john-nessime.com/blog/cloud-computing/cv-parsing-aws-textract-bedrock-bias/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sun, 30 Aug 2026 09:00:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[Compliance]]></category>
		<category><![CDATA[Data Engineering]]></category>
		<category><![CDATA[Adverse Impact]]></category>
		<category><![CDATA[Algorithmic Bias]]></category>
		<category><![CDATA[Amazon Bedrock]]></category>
		<category><![CDATA[Amazon Textract]]></category>
		<category><![CDATA[Bedrock Guardrails]]></category>
		<category><![CDATA[Confidence Scoring]]></category>
		<category><![CDATA[EU AI Act]]></category>
		<category><![CDATA[HR Technology]]></category>
		<category><![CDATA[Human In The Loop]]></category>
		<category><![CDATA[Intelligent Document Processing]]></category>
		<category><![CDATA[Local Law 144]]></category>
		<category><![CDATA[Model Invocation Logging]]></category>
		<category><![CDATA[OCR]]></category>
		<category><![CDATA[PII Redaction]]></category>
		<category><![CDATA[Resume Parsing]]></category>
		<category><![CDATA[Structured Outputs]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=540</guid>

					<description><![CDATA[<p>A CV parsing pipeline stops being a parser the moment a score sorts a list. Where Textract and Bedrock actually fit, why redacting names does nothing for bias, and what to document before anyone asks you to explain a ranking.</p>
<p>The post <a href="https://john-nessime.com/blog/cloud-computing/cv-parsing-aws-textract-bedrock-bias/">CV Parsing at Scale on AWS: Textract, Bedrock, and the Bias You Have to Document</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 ticket said &#8220;parser is working fine.&#8221; It was. Extraction confidence was high, the dead letter queue was empty, and recruiting liked the shortlists. Then someone in legal asked why candidate 4,412 ranked below candidate 118.</p>



<p class="wp-block-paragraph">Nobody could answer. Not because anything was broken, but because the system had quietly stopped being a parser somewhere between the S3 upload and the sorted list on a recruiter&#8217;s screen. It had become a selection procedure, and nothing in it was designed to explain itself.</p>



<p class="wp-block-paragraph">That is the failure mode worth leading with, because it never pages you. Your alarms stay green while the thing you built lands in a category of regulated system you never intended to build.</p>



<p class="wp-block-paragraph">This post covers CV parsing on AWS as it actually goes: what Amazon Textract gives you and what it doesn&#8217;t, where Bedrock belongs and where it becomes a liability, the bias problem you have to document rather than solve, and the failures that only surface at volume.</p>



<h2 class="wp-block-heading">When CV parsing on AWS becomes a hiring decision</h2>



<p class="wp-block-paragraph">Most of these pipelines start honestly. Someone wants structured JSON out of a PDF pile so the ATS populates fields instead of a coordinator retyping them. Extraction is a data problem with clean success criteria.</p>



<p class="wp-block-paragraph">Then a product manager asks for a match score. It seems small. You already have the skills list and the job description, so you ask a model how well they line up and emit a number. The API contract barely changes.</p>



<p class="wp-block-paragraph">What changed is everything. The moment a recruiter sorts by that number and works down from the top, the pipeline substantially assists a hiring decision. Under New York City&#8217;s Local Law 144 that is an automated employment decision tool, and the obligations attach to the employer using it, not the vendor who wrote the code. Under the EU AI Act, Annex III point 4 lists systems intended to analyse and filter job applications and evaluate candidates as high-risk by classification, not by how confident you are in your prompt.</p>



<p class="wp-block-paragraph">The EU date is worth stating plainly because it moved. Annex III obligations were originally due to apply from 2 August 2026; the Digital Omnibus package pushed stand-alone Annex III systems out to 2 December 2027. That is a reprieve on paperwork, not on design. Systems procured now will still be running then, and the separate prohibition on workplace emotion-recognition AI, plus existing GDPR duties around automated decisions, were never on that timetable.</p>



<p class="wp-block-paragraph">So the first architectural decision isn&#8217;t about services. It&#8217;s about where extraction stops and judgement begins, and whether that boundary is visible in the code or buried in a prompt.</p>



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



<h2 class="wp-block-heading">Getting text out: what Textract actually does</h2>



<p class="wp-block-paragraph">Textract is good at CVs in a narrow way. It reads printed and handwritten text, detects tables and form-style key-value pairs, and with the Layout feature type it identifies structural elements such as titles, paragraphs, lists, headers and footers. For a two-column CV with a skills sidebar, layout awareness is the difference between usable text and word salad.</p>



<h3 class="wp-block-heading">Sync versus async is not a performance choice</h3>



<p class="wp-block-paragraph"><code>AnalyzeDocument</code> is synchronous and takes a single image or a document passed as bytes or an S3 object. <code>StartDocumentAnalysis</code> is the asynchronous entry point for multipage documents; it publishes completion status to an SNS topic you drain from SQS before calling <code>GetDocumentAnalysis</code> with the returned job ID.</p>



<p class="wp-block-paragraph">People pick sync because it&#8217;s easier to reason about, then discover half of real CVs are three pages. Go async from the start. The SNS-to-SQS hop isn&#8217;t overhead, it&#8217;s the only thing that gives you a retry surface when a job fails partway through a batch.</p>



<h3 class="wp-block-heading">Queries beat walking the block graph by hand</h3>



<p class="wp-block-paragraph">The Queries feature lets you ask natural-language questions of a document without knowing where the data sits on the page. Pass <code>QUERIES</code> in <code>FeatureTypes</code>, supply a <code>QueriesConfig</code>, then read the <code>QUERY_RESULT</code> blocks out of the response.</p>



<pre class="wp-block-code"><code>aws textract start-document-analysis 
  --document '{"S3Object":{"Bucket":"bucket","Name":"document"}}' 
  --feature-types '["QUERIES"]' 
  --queries-config '{"Queries":[{"Text":"Question"}]}'</code></pre>



<p class="wp-block-paragraph">Each query can carry an alias so answers map back to your schema without positional guessing. In Python, walk the returned blocks and pick the ones whose <code>BlockType</code> is <code>QUERY_RESULT</code>, each carrying answer text and a confidence score.</p>



<pre class="wp-block-code"><code>response = client.analyze_document(
    Document={'S3Object': {'Bucket': bucket, 'Name': document}},
    FeatureTypes=["TABLES", "FORMS", "QUERIES"],
    QueriesConfig={'Queries': [{'Text': question}]}
)

for block in response['Blocks']:
    if block["BlockType"] == "QUERY_RESULT":
        print(block["Text"])</code></pre>



<p class="wp-block-paragraph">Use Queries for facts that exist verbatim on the page: employer name, job title, dates, certification number. Not for anything requiring inference. &#8220;What is this person&#8217;s seniority level?&#8221; is a judgement, and pushing it through an extraction API hides that judgement inside something that looks like OCR.</p>



<h2 class="wp-block-heading">Where Bedrock belongs, and where it stops</h2>



<p class="wp-block-paragraph">Bedrock earns its place in one job: turning messy extracted text into a consistent schema. CVs are the worst kind of semi-structured document because the structure is a fashion trend. One person lists dates as &#8220;Mar 2019 to present,&#8221; another renders them as a bar chart. Normalising that is exactly the fuzzy transformation a language model handles well, and one you can verify.</p>



<ol class="wp-block-list">
<li>Constrain output to a schema and reject anything that fails validation. A job title you didn&#8217;t ask for, or an employment date absent from the source text, is a failed record rather than a creative flourish.</li>



<li>Turn on model invocation logging before you process a single real CV. It&#8217;s disabled by default, and it&#8217;s the only native record of what you sent and what came back.</li>
</ol>



<p class="wp-block-paragraph">That second point isn&#8217;t a nice-to-have. When someone asks a year from now why a candidate scored the way they did, the invocation log is your answer or you don&#8217;t have one.</p>



<pre class="wp-block-code"><code>aws bedrock put-model-invocation-logging-configuration 
  --logging-config '{
    "s3Config": {"bucketName": "my-bedrock-logs", "keyPrefix": "cv-pipeline/"},
    "textDataDeliveryEnabled": true
  }'</code></pre>



<p class="wp-block-paragraph">Logs go to CloudWatch Logs, S3, or both, and only destinations in the same account and Region are supported. Apply lifecycle rules and encryption up front, because these logs contain full CVs and they outlive the hiring round that produced them.</p>



<p class="wp-block-paragraph">Bedrock Guardrails fits here through sensitive information filters, which detect PII in prompts and responses and either block the payload or mask individual entities with identifier tags. There&#8217;s a detect mode that reports what a guardrail would have caught without acting, which is the right way to tune against real documents first. Don&#8217;t oversell it to yourself: masking is not anonymisation, and a filter firing on <code>NAME</code> and <code>EMAIL</code> does very little about bias, for reasons that are the whole point of the next section.</p>



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



<h2 class="wp-block-heading">The bias problem you must document, not solve</h2>



<p class="wp-block-paragraph">Every engineer&#8217;s instinct is to strip the protected attributes and declare it handled. Remove the name, the photo, the address, and the model can&#8217;t discriminate on what it can&#8217;t see. It doesn&#8217;t work, and understanding why separates a pipeline you can defend from one you can&#8217;t.</p>



<h3 class="wp-block-heading">Proxies survive redaction</h3>



<p class="wp-block-paragraph">A CV is dense with correlated signal. University name tracks geography and socioeconomics. Graduation year is an age proxy that survives every name redaction you can write. Employment gaps correlate with caregiving. Postcode correlates with almost everything. Add professional association membership, the language a qualification is written in, whether military service appears, the phrasing conventions of a non-native English speaker.</p>



<p class="wp-block-paragraph">None of those are protected attributes. All of them carry information about protected attributes, and a model optimising for similarity to your existing successful hires will use them. Redaction removes the label, not the signal, and it makes disparity harder to detect because you deleted the field you would have measured against.</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph">You can&#8217;t prove a screening pipeline is unbiased. You can measure its outcomes, record what you measured, and act when the numbers move.</p>
</blockquote>



<h3 class="wp-block-heading">What the four-fifths rule actually asks for</h3>



<p class="wp-block-paragraph">The Uniform Guidelines on Employee Selection Procedures set out the rule of thumb federal enforcement agencies apply: a selection rate for any race, sex or ethnic group falling below four-fifths of the rate for the highest group is generally treated as evidence of adverse impact. It sits at 29 CFR 1607.4(D).</p>



<ul class="wp-block-list">
<li><strong>It&#8217;s a rule of thumb, not a safe harbour.</strong> The same guidelines note smaller differences can still constitute adverse impact where they&#8217;re significant in statistical and practical terms, and courts have declined to treat clearing 0.8 as proof nothing is wrong.</li>



<li><strong>It applies to the selection procedure.</strong> That means your parser, your scorer and the recruiter&#8217;s sort order taken together. Auditing the model in isolation measures the wrong thing.</li>
</ul>



<p class="wp-block-paragraph">The computation is trivial, which is why there&#8217;s no excuse for skipping it.</p>



<pre class="wp-block-code"><code>SELECT
  demographic_group,
  COUNT(*) AS applicants,
  SUM(CASE WHEN advanced THEN 1 ELSE 0 END) AS selected,
  1.0 * SUM(CASE WHEN advanced THEN 1 ELSE 0 END) / COUNT(*) AS selection_rate
FROM screening_outcomes
WHERE requisition_id = :req
GROUP BY demographic_group;</code></pre>



<p class="wp-block-paragraph">Divide each selection rate by the largest and you have the impact ratio. Anything under 0.8 is a signal to stop and look, not a number to explain away.</p>



<h3 class="wp-block-heading">Where the demographic data comes from</h3>



<p class="wp-block-paragraph">This is the genuinely hard part and where most teams stall. You need demographic data to measure disparity, and you must not feed it into the screening decision. The pattern that works is a strict split: self-reported responses collected separately at application time, stored in a different bucket under a different KMS key, joined to outcomes only in an analysis account the scoring pipeline has no path to.</p>



<p class="wp-block-paragraph">NYC&#8217;s regime allows historical or test data, and requires the audit be performed by someone not involved in using or developing the tool. That independence requirement is easy to miss when you&#8217;re the consultant who built the thing. You can build the measurement harness. You can&#8217;t be the independent auditor of your own pipeline.</p>



<h3 class="wp-block-heading">Document the pipeline, not the model</h3>



<p class="wp-block-paragraph">The artifact that saves you is boring and written down. At minimum: which model and version produced scores in each date range, the scoring logic in force, every field that reached the scorer, every field withheld and why, where human review sits and what a reviewer can override, and the impact ratios from each measurement run.</p>



<p class="wp-block-paragraph">Keep it in version control beside the infrastructure code. A change to the prompt is a change to the selection procedure and should be reviewable as one.</p>



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



<h2 class="wp-block-heading">Problems that only appear at volume</h2>



<ul class="wp-block-list">
<li><strong>Throttling arrives as data loss.</strong> Without retries using exponential backoff and jitter, a throttled call becomes a missing candidate rather than an error anyone sees. Assert on record counts at every stage boundary.</li>



<li><strong>Duplicates are the norm.</strong> The same person applies to three roles with two slightly different files. Deduplicate on content hash before you spend on extraction, and keep an idempotency key so a retried job doesn&#8217;t create a second candidate record.</li>



<li><strong>Cost is dominated by re-runs.</strong> Extraction is the expensive step and it&#8217;s deterministic on the same input. Store the raw Textract response in S3 keyed by document hash. Reprocessing after a schema change should cost storage, not inference.</li>



<li><strong>Retention outlives the requisition.</strong> Extracted text, invocation logs and intermediate JSON all contain personal data and all quietly accumulate. Set lifecycle rules at build time, not after the first data subject request.</li>



<li><strong>Scanned CVs are a different pipeline.</strong> Photographed documents behave nothing like exported PDFs. Route them separately with lower confidence thresholds and higher review rates.</li>
</ul>



<p class="wp-block-paragraph">On cost visibility, native tooling shows the shape of spend but is slow to attribute it. If this runs alongside other AI workloads, a dedicated platform such as Vantage or CloudZero makes per-pipeline attribution less painful than slicing Cost Explorer by tag every month. And if the upload front door is public, putting Cloudflare in front of it deals with bot traffic before it becomes Textract spend.</p>



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



<ul class="wp-block-list">
<li><strong>High confidence, wrong fields.</strong> Almost always multi-column layouts read in the wrong order. Add the Layout feature type and check reading order in the block graph against what a human sees.</li>



<li><strong>Async jobs complete but results never arrive.</strong> Check the SNS topic policy allows publishing to your queue and that the role passed to Textract can publish. This fails silently and looks like a stuck pipeline.</li>



<li><strong>The model invents employment history.</strong> Your prompt is asking it to summarise rather than extract. Constrain the schema, require every value trace to source text, and fail validation instead of accepting a plausible answer.</li>



<li><strong>Scores shift after a model update.</strong> Pin the model identifier rather than tracking a moving alias, and re-run your measurement set against a held-out corpus before promoting any change.</li>



<li><strong>Guardrails block legitimate CVs.</strong> Sensitive information filters are probabilistic and context-dependent. Run detect mode against a real corpus and read the traces before switching to block.</li>
</ul>



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



<ul class="wp-block-list">
<li>Treating a match score as a data field rather than a decision, and skipping the governance that follows.</li>



<li>Redacting names and considering the bias question closed.</li>



<li>Enabling invocation logging after the pilot, leaving the first thousand decisions with no record.</li>



<li>Building human review that only sees the shortlist, which reviews the output of the bias rather than the bias.</li>



<li>Assuming a US-headquartered employer is out of scope. Both regimes reach based on where the candidate is, not where the company is.</li>
</ul>



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



<ul class="wp-block-list">
<li>Draw a hard line between extraction and scoring: separate services, separate IAM roles, separate logs.</li>



<li>Enable model invocation logging before the first real document, with encryption and lifecycle rules already applied.</li>



<li>Cache raw extraction output keyed by document hash so reprocessing never re-bills inference.</li>



<li>Collect demographic data separately, key it differently, and join it to outcomes only in an isolated analysis account.</li>



<li>Run impact ratios on a schedule rather than on request, and alert on movement instead of waiting for an audit.</li>



<li>Version prompts and scoring logic in the same repository as the infrastructure, reviewed as changes to a selection procedure.</li>
</ul>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">Is Textract or Bedrock the right tool for CV parsing?</h3>



<p class="wp-block-paragraph">Both, for different halves. Textract turns pixels into text with layout awareness and confidence scores. Bedrock turns that text into a consistent schema. Using a language model for the OCR step gives up per-field confidence, which is exactly the signal you need to decide what goes to human review.</p>



<h3 class="wp-block-heading">Does removing names and photos make a resume screening pipeline compliant?</h3>



<p class="wp-block-paragraph">No. Redaction handles direct identifiers while leaving proxies such as graduation year, institution, postcode and employment gaps intact. It also removes the fields you&#8217;d need to measure disparity, so it can make a pipeline harder to audit while feeling safer.</p>



<h3 class="wp-block-heading">Do these rules apply if my company isn&#8217;t in New York or the EU?</h3>



<p class="wp-block-paragraph">Possibly. Local Law 144 turns on whether the candidate resides in New York City, including for remote roles, rather than where the employer sits. The EU AI Act has similar extraterritorial reach. Check with counsel, not with an architecture diagram.</p>



<h3 class="wp-block-heading">Can I run the bias audit myself?</h3>



<p class="wp-block-paragraph">You can build the measurement pipeline and run it continuously, and you should. The formal NYC bias audit has to be performed by an independent party not involved in using or developing the tool, which rules out the team that built it.</p>



<h3 class="wp-block-heading">How do I keep extraction costs predictable at scale?</h3>



<p class="wp-block-paragraph">Deduplicate before extraction, cache raw responses by content hash, and choose feature types deliberately. Requesting tables, forms and queries on every document when you only need queries multiplies cost for nothing. Both services bill per unit of work, so the lever is doing less work rather than finding a cheaper tier.</p>



<h3 class="wp-block-heading">Should the pipeline auto-reject candidates?</h3>



<p class="wp-block-paragraph">Treat that as a question for counsel, not engineering. GDPR restricts solely automated decisions with significant effects on individuals, and the practical answer in most designs is a meaningful human review before any rejection. Build the human step in early; retrofitting it is painful.</p>



<h2 class="wp-block-heading">The one thing worth remembering</h2>



<p class="wp-block-paragraph">CV parsing on AWS is two systems wearing one name. The extraction half is an engineering problem with clean success criteria, and Textract plus Bedrock will carry you a long way given retries, caching and schema validation. The scoring half is a selection procedure, governed by rules that don&#8217;t care what you called the service.</p>



<p class="wp-block-paragraph">You won&#8217;t eliminate bias from a system trained on hiring history. What you can do is know where the boundary sits, log what crossed it, measure outcomes on a schedule, and be able to answer the question about candidate 4,412 when it arrives. Build the documentation while you build the pipeline. Reconstructing it later from CloudWatch and memory is a much worse job.</p>



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



<h2 class="wp-block-heading">Work with me on document pipelines and AI governance</h2>



<p class="wp-block-paragraph">Most of the work here isn&#8217;t the extraction. It&#8217;s the boundary drawing, the evidence trail and the parts that only break at volume. Things I help with:</p>



<ul class="wp-block-list">
<li>Designing Textract and Bedrock pipelines with an auditable split between extraction and any scoring or ranking step.</li>



<li>Building the measurement harness that computes selection rates and impact ratios on a schedule, with demographic data isolated from the scoring path.</li>



<li>Setting up model invocation logging, retention, encryption and access controls so the evidence trail exists before you need it.</li>



<li>Fixing throughput and cost problems: throttling, deduplication, extraction caching and reprocessing that doesn&#8217;t re-bill inference.</li>



<li>Adding human-in-the-loop review that routes on confidence and score bands, including sampled rejections rather than shortlists only.</li>



<li>Writing the technical documentation an independent auditor or your legal team will actually ask for.</li>
</ul>



<p class="wp-block-paragraph">Send me a sample Textract response, a scoring prompt or an architecture sketch and I&#8217;ll tell you where the boundary is currently blurred.</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/cloud-computing/cv-parsing-aws-textract-bedrock-bias/">CV Parsing at Scale on AWS: Textract, Bedrock, and the Bias You Have to Document</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>One Customer, Four Systems: Unifying Shopify, Stripe, CRM and Support Data</title>
		<link>https://john-nessime.com/blog/technical-guides/unify-shopify-stripe-crm-support-data/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sat, 29 Aug 2026 18:00:00 +0000</pubDate>
				<category><![CDATA[Data Engineering]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Customer 360]]></category>
		<category><![CDATA[Customer Support Analytics]]></category>
		<category><![CDATA[Data Integration]]></category>
		<category><![CDATA[Data Quality]]></category>
		<category><![CDATA[Data Warehouse]]></category>
		<category><![CDATA[dbt]]></category>
		<category><![CDATA[Ecommerce Analytics]]></category>
		<category><![CDATA[ETL]]></category>
		<category><![CDATA[Event-Driven Architecture]]></category>
		<category><![CDATA[Idempotency]]></category>
		<category><![CDATA[Identity Resolution]]></category>
		<category><![CDATA[PII Redaction]]></category>
		<category><![CDATA[Reverse ETL]]></category>
		<category><![CDATA[Schema Design]]></category>
		<category><![CDATA[Shopify]]></category>
		<category><![CDATA[Stripe]]></category>
		<category><![CDATA[Webhooks]]></category>
		<category><![CDATA[Zendesk]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=350</guid>

					<description><![CDATA[<p>Three CRM records, one person, and nothing in the logs to explain it. A practical walkthrough of what actually breaks when you unify Shopify, Stripe, CRM and support data: guest-checkout identity, duplicate and out-of-order webhooks, revenue that never ties to payouts, deletion requests that miss half your copies, and reverse ETL write loops. Organized by failure family, with schema and handler patterns you can apply directly.</p>
<p>The post <a href="https://john-nessime.com/blog/technical-guides/unify-shopify-stripe-crm-support-data/">One Customer, Four Systems: Unifying Shopify, Stripe, CRM and Support Data</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 project usually starts with a screenshot. Someone in support pastes three CRM records into a channel, all carrying the same person&#8217;s name, and asks which one is real. One has the order history. One has the subscription. The third has four tickets attached and no orders at all.</p>



<p class="wp-block-paragraph">Nobody broke anything. Shopify, Stripe, the CRM and the helpdesk each created a customer record using whatever identifier they had at the moment they needed one, and none of those identifiers agreed. The systems are all correct in isolation. The person is the thing that got lost.</p>



<p class="wp-block-paragraph">This post covers what actually breaks when you unify Shopify, Stripe, CRM and support data, and how to build the join so it survives guest checkouts, duplicate webhooks, refunds, currency conversion and deletion requests. It&#8217;s organized by failure family rather than by tool, because the tools get acquired and renamed every couple of years and the failures never change.</p>



<h2 class="wp-block-heading">Failure family one: the join key is not the email address</h2>



<p class="wp-block-paragraph">Every one of these systems will happily give you an email address, which is exactly why so many pipelines join on it and quietly produce garbage.</p>



<p class="wp-block-paragraph">Start with where identity actually lives in each system:</p>



<ul class="wp-block-list">
<li><strong>Shopify</strong> has a customer ID, but an order does not have to be attached to one. Guest checkout produces an order with an email on it and no durable customer record behind it.</li>

<li><strong>Stripe</strong> has a customer ID, but only if a Customer object was created. Checkout Sessions that don&#8217;t create one are associated with guest customers instead, which Stripe groups in the Dashboard based on the same card, email or phone. That grouping is read-only and it is not an ID you can join on.</li>

<li><strong>The CRM</strong> keys on email in almost every default configuration, and cheerfully creates a second contact when the email differs by one character.</li>

<li><strong>The helpdesk</strong> keys on the requester&#8217;s email, which is whatever the person typed into a contact form or sent the ticket from. It is very often not the address on the order.</li>
</ul>



<p class="wp-block-paragraph">Here&#8217;s the part that stays invisible until someone finally checks: nothing errors. Every dashboard still returns a number. Lifetime value gets silently split across three profiles, so your LTV looks lower and your repeat-purchase rate looks worse than it is. Support metrics look <em>better</em> than they are, because a customer coming back with the same unresolved problem shows up as a brand new contact. You can run like that for a year without a single failed job.</p>



<h3 class="wp-block-heading">Build an identifier table, not a fuzzy match</h3>



<p class="wp-block-paragraph">The fix is boring and it works: a surrogate person key, and a separate table of every identifier ever observed for that person, with the source that reported it.</p>



<pre class="wp-block-code"><code>-- One row per real human, generated by you, owned by you.
CREATE TABLE person (
  person_key      uuid PRIMARY KEY,
  created_at      timestamptz NOT NULL DEFAULT now(),
  merged_into     uuid NULL REFERENCES person(person_key)
);

-- One row per identifier per source. This is the whole trick.
CREATE TABLE person_identifier (
  person_key      uuid NOT NULL REFERENCES person(person_key),
  source          text NOT NULL,   -- shopify | stripe | crm | helpdesk
  id_type         text NOT NULL,   -- email | phone | shopify_customer_id | stripe_customer_id
  id_value        text NOT NULL,   -- already normalized
  first_seen_at   timestamptz NOT NULL,
  last_seen_at    timestamptz NOT NULL,
  PRIMARY KEY (source, id_type, id_value)
);</code></pre>



<p class="wp-block-paragraph">Normalization happens before the row is written, not in the join. Lowercase and trim emails, store phone numbers in E.164, strip whitespace from external IDs. Resist the urge to get clever with Gmail&#8217;s dot and plus handling. Stripping <code>+tags</code> is a business policy decision with real consequences, not a technical detail, and the same rule applied to a domain that treats plus addresses as distinct mailboxes will merge two genuinely different people.</p>



<p class="wp-block-paragraph">Deterministic matching first, always. Exact matches on shared identifiers, transitively closed. Probabilistic matching on name plus address plus device is worth reaching for only when you have exhausted the deterministic keys, and it has an asymmetric failure mode: a household sharing one card gets collapsed into one person, and unmerging afterwards is expensive because every downstream system has already copied the merged key into its own records. Merges are cheap to make and painful to undo. Bias toward leaving two records separate.</p>



<p class="wp-block-paragraph">One thing worth doing early on the Stripe side: when you create a Customer, write your own identifier into <code>metadata</code>. Stripe allows fifty key-value pairs per object, with keys up to forty characters and values up to five hundred, and it only returns metadata for requests made with a secret key. That&#8217;s enough room for a person key and an order reference, which turns a future fuzzy join into an exact one. Never put anything sensitive there.</p>



<h2 class="wp-block-heading">Failure family two: webhooks arrive twice, out of order, or not at all</h2>



<p class="wp-block-paragraph">Both platforms are explicit about this, and both get treated as if they weren&#8217;t.</p>



<p class="wp-block-paragraph">Stripe delivers at least once, does not guarantee ordering, and retries failed deliveries with exponential backoff for up to three days. The event <code>id</code> (the one starting <code>evt_</code>) stays identical across every retry, which makes it your deduplication key. Shopify sends <code>X-Shopify-Webhook-Id</code> for the same purpose, signs the raw body into <code>X-Shopify-Hmac-Sha256</code>, and states plainly that ordering isn&#8217;t guaranteed within a topic or across topics for the same resource. Shopify&#8217;s own documentation goes further and tells you not to rely on webhooks as your only source of data.</p>



<p class="wp-block-paragraph">For a customer-data pipeline specifically, out-of-order delivery is nastier than duplication. Consider a <code>customers/update</code> that changes someone&#8217;s email arriving before the <code>orders/create</code> that was placed under the old address. Process those in arrival order and you attach the order to a person who doesn&#8217;t exist yet, or worse, to the wrong one. Order your writes by the event&#8217;s own timestamp, using <code>X-Shopify-Triggered-At</code> or the <code>updated_at</code> in the payload, and make profile updates last-writer-wins on that timestamp rather than on insertion time.</p>



<h3 class="wp-block-heading">Do almost nothing in the handler</h3>



<p class="wp-block-paragraph">Verify the signature, claim the event ID, write the raw body to durable storage, return 2xx. That&#8217;s it. Identity resolution, CRM lookups and enrichment calls all belong in a worker reading from that store. A handler that tries to resolve identity inline will eventually be slow enough to time out, which triggers a retry, which arrives while the first attempt is still running.</p>



<p class="wp-block-paragraph">Make the claim atomic rather than check-then-act, because two retries genuinely can land concurrently:</p>



<pre class="wp-block-code"><code>-- Atomic claim. If this inserts zero rows, someone already has it.
INSERT INTO webhook_event (source, event_id, topic, received_at, payload)
VALUES ($1, $2, $3, now(), $4)
ON CONFLICT (source, event_id) DO NOTHING
RETURNING event_id;</code></pre>



<p class="wp-block-paragraph">Return a 2xx for a duplicate. A duplicate is a success from the sender&#8217;s point of view, and returning an error just schedules more of them.</p>



<p class="wp-block-paragraph">Signature verification has one recurring trap on the Shopify side: it runs against the <em>raw</em> body. Any JSON body parser that runs first will change the bytes and every signature will fail. Capture the raw body before parsing, and compare in constant time.</p>



<pre class="wp-block-code"><code>import base64, hashlib, hmac

def verify_shopify(raw_body: bytes, header_value: str, client_secret: str) -&gt; bool:
    digest = hmac.new(
        client_secret.encode("utf-8"),
        raw_body,
        hashlib.sha256,
    ).digest()
    expected = base64.b64encode(digest).decode("utf-8")
    return hmac.compare_digest(expected, header_value)</code></pre>



<p class="wp-block-paragraph">Worth knowing before you spend an afternoon on it: after rotating an app&#8217;s client secret, Shopify notes it can take up to an hour before signatures are generated with the new one. If verification starts failing right after a rotation, that&#8217;s usually why.</p>



<p class="wp-block-paragraph">And because delivery isn&#8217;t guaranteed, webhooks alone are never enough. Run a reconciliation job that pulls orders and customers modified since the last watermark and fills the gaps. This is the job that saves you during an outage on your side, and it&#8217;s the one people skip. If you&#8217;d rather not build the buffering and replay layer, managed webhook infrastructure like Hookdeck exists for exactly this, and self-hosting a small queue and worker on a modest VPS from somewhere like Contabo or InterServer is perfectly reasonable too. The point is that something has to catch what the webhook missed.</p>



<h2 class="wp-block-heading">Failure family three: &#8220;revenue&#8221; means four different things</h2>



<p class="wp-block-paragraph">This is the one that turns into a meeting. Finance says the number is wrong. Analytics says the number is right. Both are correct, because they&#8217;re measuring different things and nobody wrote down which.</p>



<ul class="wp-block-list">
<li>Shopify reports gross sales at the moment the order is placed, in the store&#8217;s currency and the store&#8217;s timezone.</li>

<li>Stripe pays out in batches, net of processing fees, refunds and disputes, on its own schedule, in the settlement currency of the account.</li>

<li>A refund issued in Shopify and a refund issued in Stripe are two different events that may or may not be the same underlying money movement.</li>

<li>A dispute lands days or weeks after the order and reverses revenue in a period that has already been reported.</li>
</ul>



<p class="wp-block-paragraph">There&#8217;s a structural trap here too. If the store runs Shopify Payments, there is no Stripe account for you to query. Shopify Payments is built on Stripe&#8217;s processing infrastructure, but it&#8217;s Shopify&#8217;s gateway, and payouts, fees and disputes live in the Shopify admin rather than behind a Stripe API key. Plenty of businesses end up with Shopify Payments handling storefront orders and a separate Stripe account handling subscriptions or invoicing, which means two payment systems, two payout schedules and one customer who has no idea any of this exists.</p>



<p class="wp-block-paragraph">Do not build a single <code>revenue</code> column. Build separate fact tables at their natural grain (orders, order line items, payments, refunds, disputes, payouts) plus a bridging table that links a payout back to the transactions inside it. Then let each team define its own metric on top. Finance gets net settled cash. Marketing gets gross booked revenue. Neither has to be wrong for the other to be right.</p>



<p class="wp-block-paragraph">Store every timestamp in UTC and keep the original timezone as a separate column. Store every amount in minor units as an integer alongside its currency code, and store the FX rate that was actually applied rather than recalculating it later from a rates table. Recalculated FX is the single most common source of &#8220;the number moved and nobody changed anything.&#8221;</p>



<p class="wp-block-paragraph">On ingestion, Stripe Data Pipeline can push Stripe data directly into Snowflake, Redshift or Databricks as a data share, which removes a connector from the picture if you&#8217;re already on one of those. Otherwise a managed ELT tool like Fivetran or a self-hosted Airbyte will handle Shopify, Stripe, the CRM and the helpdesk with off-the-shelf connectors. Both routes are fine. The connector is genuinely the easy part.</p>



<h2 class="wp-block-heading">Failure family four: deletion is the requirement nobody models</h2>



<p class="wp-block-paragraph">If you&#8217;re building this as a Shopify app, three compliance webhooks are mandatory: <code>customers/data_request</code>, <code>customers/redact</code> and <code>shop/redact</code>. You acknowledge with a 200 and complete the action within thirty days. <code>shop/redact</code> fires forty-eight hours after uninstall and covers the shop&#8217;s data. These are enforced at app review, so you&#8217;ll find out about them either way.</p>



<p class="wp-block-paragraph">The interesting problem isn&#8217;t receiving the webhook. It&#8217;s that by the time it arrives, that person&#8217;s email exists in your raw landing zone, your staging tables, your identity table, your customer view, a BI extract, the CRM, the helpdesk, and a Slack message from a debugging session six months ago. A delete that only touches the golden record is not a delete.</p>



<p class="wp-block-paragraph">The design decision that makes this tractable is to hold personal data in exactly one place. Every downstream table carries <code>person_key</code> and nothing else. Facts, aggregates, models, extracts and syncs all reference the key. When a redaction request lands, you redact one row set in the identifier table and the rest of the warehouse is already anonymous. Getting this right at schema-design time costs an afternoon; retrofitting it costs a quarter.</p>



<p class="wp-block-paragraph">Practical pieces that make it real:</p>



<ul class="wp-block-list">
<li>Tag every table and column that can contain an identifier, in your catalog or in dbt metadata, and treat the tag list as the deletion scope.</li>

<li>Partition the raw landing zone so you can rewrite a partition rather than scan an entire bucket looking for one email.</li>

<li>Keep a deletion queue with a status per downstream system, including the CRM and the helpdesk, which have their own APIs and their own idea of what deletion means.</li>

<li>Write down what you retain and why. Financial records generally have to survive a deletion request for tax and dispute reasons. Say so rather than promising a hard delete you can&#8217;t perform.</li>
</ul>



<h2 class="wp-block-heading">Failure family five: pushing the unified view back out</h2>



<p class="wp-block-paragraph">A customer view nobody can act on is a report. The value shows up when a support agent opens a ticket and can see subscription status and lifetime order count without switching tabs.</p>



<p class="wp-block-paragraph">That&#8217;s reverse ETL, and the market is well served: Hightouch, Census (now part of Fivetran), Polytomic, RudderStack, and iPaaS-style tools like Workato if you need branching logic rather than field syncs. The warehouse-native argument is straightforward and mostly correct: your models already live there, and you avoid maintaining a second copy of the customer profile inside a vendor&#8217;s rigid schema. The counter-argument is also real. Reverse ETL is batch by nature, so if you need sub-minute reaction to an event, a packaged CDP or a direct event pipeline will serve you better, and you&#8217;ll be running several vendors plus warehouse compute plus the people to maintain the models.</p>



<p class="wp-block-paragraph">Three things go wrong here more or less every time.</p>



<p class="wp-block-paragraph"><strong>The write loop.</strong> You compute a churn-risk field and sync it into the CRM. Your ELT connector reads the CRM back into the warehouse. Now the computed field is an input to the model that computes it. This is genuinely hard to spot because nothing errors, the value just drifts. Namespace every synced field with a consistent prefix and explicitly exclude that namespace from ingestion.</p>



<p class="wp-block-paragraph"><strong>The resync stampede.</strong> Incremental syncs push diffs and stay small. Change a model definition and the next run wants to update every record, which will meet a helpdesk API rate limit at speed. Cap the batch size, and treat a full resync as a planned operation rather than something a merge to main can trigger.</p>



<p class="wp-block-paragraph"><strong>Syncing logic instead of keys.</strong> Push <code>person_key</code> into a custom field on the CRM contact and the helpdesk user. It makes the key searchable by agents, it gives the next pipeline a deterministic join, and it stops three teams from each rebuilding their own version of your matching rules.</p>



<h2 class="wp-block-heading">A pipeline shape that holds up</h2>



<p class="wp-block-paragraph">Put together, the order of operations matters more than the tool choice:</p>



<ol class="wp-block-list">
<li>Land raw. Every webhook body and every API page goes to immutable storage first, keyed by source and event ID, before anything interprets it.</li>

<li>Deduplicate on the provider&#8217;s event ID, atomically, with a retention window longer than the provider&#8217;s retry window.</li>

<li>Normalize identifiers into the identifier table as rows arrive, with the source recorded.</li>

<li>Resolve identity deterministically into <code>person_key</code>, as a scheduled job, never inside a request handler.</li>

<li>Build fact tables per system at their own grain, keyed on <code>person_key</code>, with no raw PII.</li>

<li>Model the customer view on top, in dbt or whatever your team already uses. The Fivetran <code>dbt_customer360</code> package is a reasonable starting point if you want to see how someone else structured the identity step.</li>

<li>Activate a small, named, namespaced set of fields back into the CRM and helpdesk.</li>

<li>Run reconciliation and freshness checks on a schedule, and alert on them like you would any other production job.</li>
</ol>



<h2 class="wp-block-heading">Troubleshooting: symptom to root cause</h2>



<p class="wp-block-paragraph">These are the ones that come up repeatedly, and the check that usually settles them.</p>



<ul class="wp-block-list">
<li><strong>Warehouse customer count is much higher than Shopify&#8217;s.</strong> Guest orders are minting a new person on every purchase. Check how many <code>person</code> rows have exactly one identifier and one order.</li>

<li><strong>Warehouse count is much lower.</strong> An over-eager merge rule. Look for person keys with an implausible number of distinct emails or shipping addresses attached.</li>

<li><strong>BI revenue doesn&#8217;t match Stripe payouts.</strong> Grain mismatch, not a data loss. Reconcile a single payout against its constituent transactions before touching anything else.</li>

<li><strong>A CRM field keeps flipping between two values.</strong> Write loop. Check whether the field&#8217;s namespace is excluded from your ELT source configuration.</li>

<li><strong>Recent orders missing, older ones fine.</strong> Webhook delivery gap. Confirm the reconciliation job ran, and check its watermark rather than trusting its exit code.</li>

<li><strong>Every Shopify signature suddenly fails.</strong> Either a body parser is running before verification, or the client secret was rotated within the last hour.</li>

<li><strong>Tickets attached to the wrong customer after a merge.</strong> The merge happened in the warehouse but the synced key in the helpdesk still points at the old person. Merges need to propagate outward, not just inward.</li>
</ul>



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



<ul class="wp-block-list">
<li>Joining on email in the model layer instead of resolving identity once, upstream.</li>

<li>Treating webhooks as the system of record instead of as a low-latency hint, with a reconciliation pull behind them.</li>

<li>Doing identity resolution, enrichment or CRM writes inside the webhook handler.</li>

<li>Ordering profile updates by arrival time rather than by the event&#8217;s own timestamp.</li>

<li>One <code>revenue</code> column that finance and marketing are both told to use.</li>

<li>Storing amounts as floats, or recalculating historical FX from a rates table.</li>

<li>Copying PII into every downstream table because it was convenient at the time.</li>

<li>Reaching for probabilistic matching before the deterministic keys have been exhausted.</li>

<li>Assuming a Shopify store on Shopify Payments has a Stripe account you can query.</li>
</ul>



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



<ul class="wp-block-list">
<li>Own the surrogate key. Never let a vendor&#8217;s internal ID become the primary identity of a person in your systems.</li>

<li>Write your <code>person_key</code> into Stripe <code>metadata</code> and Shopify customer metafields at creation time, so future joins are exact rather than inferred.</li>

<li>Keep raw payloads immutable and replayable. Every model bug you&#8217;ll ever have is cheaper to fix if you can reprocess from raw.</li>

<li>Version your matching rules and record which version produced each merge, so you can audit and reverse a bad rule.</li>

<li>Alert on freshness and on row-count deltas per source, not just on job success. A connector returning zero rows exits cleanly.</li>

<li>Reconcile one payout end to end by hand before you trust the automated version. It&#8217;s the fastest way to find a grain error.</li>

<li>Document the metric definitions next to the models. Most &#8220;the data is wrong&#8221; tickets are definition disputes.</li>
</ul>



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



<h3 class="wp-block-heading">Do I need a CDP to unify Shopify, Stripe, CRM and support data?</h3>



<p class="wp-block-paragraph">Not necessarily. If you already run a warehouse and have someone comfortable with dbt, ELT plus modelling plus reverse ETL covers the same ground with more control over the data model. A packaged CDP earns its cost when you need real-time behavioural segmentation, when nobody on the team can maintain models, or when marketing needs to ship changes without waiting on data engineering.</p>



<h3 class="wp-block-heading">How do I handle guest checkout customers?</h3>



<p class="wp-block-paragraph">Create a person from the order&#8217;s email anyway, and mark the record as identity-weak. When that email later appears attached to a real Shopify customer or Stripe Customer object, the deterministic match links them and the order history follows. The mistake is discarding guest orders because they have no customer ID, which is exactly the revenue you most want attributed.</p>



<h3 class="wp-block-heading">Should I sync everything into the CRM or keep it in the warehouse?</h3>



<p class="wp-block-paragraph">Keep the full picture in the warehouse and sync a deliberately small set of fields outward. The test is whether an agent or a rep will act on the field during a conversation. Lifetime value, subscription status, open ticket count and churn risk pass. A hundred-column profile does not, and it turns every model change into a rate-limit incident.</p>



<h3 class="wp-block-heading">Why don&#8217;t my Shopify sales match my Stripe payouts?</h3>



<p class="wp-block-paragraph">Because they&#8217;re measuring different things at different times. Shopify shows gross sales at order time; Stripe pays out batched amounts net of fees, refunds and disputes, and the timing differs by days. Add currency conversion and a timezone mismatch and the numbers will never tie without an explicit bridging table linking payouts to the transactions inside them.</p>



<h3 class="wp-block-heading">Can I use email as the primary key if my store requires account creation?</h3>



<p class="wp-block-paragraph">You can get away with it longer, but it&#8217;s still a bad primary key. People change email addresses, use one address for orders and another for support, and share addresses within a household. A surrogate key with an identifier table costs very little up front and means an email change is a new row rather than a broken join and a duplicate customer.</p>



<h3 class="wp-block-heading">How do I test the identity resolution before trusting it?</h3>



<p class="wp-block-paragraph">Run it in shadow mode. Compute person keys without activating them, then have support pick twenty customers they know are duplicates and twenty they know are distinct, and check what the resolver did. That exercise finds normalization bugs and over-eager merge rules faster than any aggregate metric, because the people who answer the tickets already know where the duplicates are.</p>



<h3 class="wp-block-heading">What&#8217;s the minimum viable version of this?</h3>



<p class="wp-block-paragraph">Raw landing with deduplication, an identifier table with deterministic matching, one fact table per source, and a single synced field into the helpdesk. That&#8217;s a couple of weeks of work and it removes most of the pain. Everything above it is refinement.</p>



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



<h2 class="wp-block-heading">Conclusion: unify Shopify, Stripe, CRM and support data around a key you own</h2>



<p class="wp-block-paragraph">If you take one thing from this: the hard part of unifying Shopify, Stripe, CRM and support data is not moving the data. Connectors are commodity. The hard part is deciding what a customer <em>is</em>, writing that decision down as a key you own, and then refusing to let any downstream system quietly invent its own version of it.</p>



<p class="wp-block-paragraph">Do the identity work first, land everything raw so you can reprocess when you get it wrong, and keep personal data in one place so deletion stays a single operation. The dashboards will follow. Skip that order and you get exactly the screenshot this post opened with, eighteen months later and with a lot more rows behind it.</p>



<h2 class="wp-block-heading">Need help wiring this together?</h2>



<p class="wp-block-paragraph">I work on exactly this kind of plumbing: the unglamorous layer between an ecommerce platform, a payment processor and the tools your team actually opens all day. Things I can help with here:</p>



<ul class="wp-block-list">
<li>Designing the identity model (surrogate keys, identifier tables, deterministic matching rules) and auditing an existing one for over-merging.</li>

<li>Building idempotent, signature-verified webhook intake for Shopify and Stripe, with a dead-letter path and a reconciliation job behind it.</li>

<li>Setting up ELT into Snowflake, BigQuery, Redshift or Postgres, whether with Fivetran, self-hosted Airbyte, or Stripe Data Pipeline where it fits.</li>

<li>Modelling orders, payments, refunds, disputes and payouts so finance and marketing can each get a number they trust.</li>

<li>Reverse ETL back into the CRM and helpdesk without write loops, rate-limit incidents or a hundred junk fields.</li>

<li>Making deletion and data-subject requests actually work across the warehouse, the raw landing zone and every downstream copy.</li>
</ul>



<p class="wp-block-paragraph">If you&#8217;re stuck on a specific piece, send me the concrete thing: a webhook payload, a schema dump, the query that disagrees with your Stripe payout report. It&#8217;s much easier to say something useful about a real artifact than about a diagram.</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/technical-guides/unify-shopify-stripe-crm-support-data/">One Customer, Four Systems: Unifying Shopify, Stripe, CRM and Support Data</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Customer Sentiment Analysis From CRM and Support Data: Building a Score You Can Actually Trust</title>
		<link>https://john-nessime.com/blog/technical-guides/customer-sentiment-analysis-crm-support-data/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sat, 22 Aug 2026 13:00:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Amazon Comprehend]]></category>
		<category><![CDATA[Compliance]]></category>
		<category><![CDATA[CRM Analytics]]></category>
		<category><![CDATA[Customer Support Analytics]]></category>
		<category><![CDATA[Data Engineering]]></category>
		<category><![CDATA[Data Quality]]></category>
		<category><![CDATA[EU AI Act]]></category>
		<category><![CDATA[NLP]]></category>
		<category><![CDATA[PII Redaction]]></category>
		<category><![CDATA[Pipeline Design]]></category>
		<category><![CDATA[Python]]></category>
		<category><![CDATA[Salesforce]]></category>
		<category><![CDATA[Sentiment Analysis]]></category>
		<category><![CDATA[SQL]]></category>
		<category><![CDATA[Zendesk]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=250</guid>

					<description><![CDATA[<p>Most customer sentiment analysis pipelines run perfectly and still produce a number nobody should act on. Here are the failure families that cause it, from labels stamped on the wrong message to averages taken over a scale that was never numeric, plus a pipeline shape and a legal boundary worth knowing before you ship.</p>
<p>The post <a href="https://john-nessime.com/blog/technical-guides/customer-sentiment-analysis-crm-support-data/">Customer Sentiment Analysis From CRM and Support Data: Building a Score You Can Actually Trust</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 CX lead pings you on a Thursday afternoon: &#8220;Sentiment has been sitting at 3.2 for four months. Is the pipeline broken?&#8221;</p>



<p class="wp-block-paragraph">You check. Every extraction job ran. Every ticket got a label. No rows dropped, no schema drift, no dead letters. Nothing is broken. The number is just useless, and it has been useless since the day it shipped.</p>



<p class="wp-block-paragraph">That is the normal outcome for customer sentiment analysis built on CRM and support data. The connectors work, the model returns labels, the dashboard renders on schedule, and the output still cannot tell anyone whether customers are getting happier or angrier. These are not pipeline failures. They are measurement failures, which is exactly why your monitoring never catches them.</p>



<p class="wp-block-paragraph">This post walks through the failure families I check first when a sentiment programme is producing numbers nobody trusts: where the label gets stamped, what happens when you average an ordinal scale, who is missing from the data entirely, why a general-purpose model misreads support English, where the CRM join breaks, and one regulatory boundary that catches more teams than it should. Then a pipeline shape that holds up, and the checks worth running before anyone puts this on a slide.</p>



<h2 class="wp-block-heading">What the score is actually measuring</h2>



<p class="wp-block-paragraph">Before anything else, get specific about the unit of analysis. &#8220;Customer sentiment&#8221; is a phrase people use for at least four different measurements:</p>



<ul class="wp-block-list">
<li><strong>Message sentiment</strong> — the tone of one comment in one thread.</li>

<li><strong>Ticket sentiment</strong> — usually the first inbound message, sometimes a rollup.</li>

<li><strong>Contact sentiment</strong> — one human&#8217;s tone across all their tickets.</li>

<li><strong>Account sentiment</strong> — the thing the business actually cares about, and the one nobody computes correctly.</li>
</ul>



<p class="wp-block-paragraph">Most stacks compute the second and label it the fourth. That single substitution causes more bad decisions than any model choice you will make downstream. A model with mediocre accuracy applied at the right unit beats an excellent model applied at the wrong one.</p>



<h2 class="wp-block-heading">Failure one: the label is stamped at the wrong moment</h2>



<p class="wp-block-paragraph">This is the expensive one, and it is invisible from the pipeline side because nothing errors.</p>



<p class="wp-block-paragraph">Zendesk&#8217;s intelligent triage is the clearest example because the documentation is honest about it. Zendesk states that most intelligent triage classifications are based on a ticket&#8217;s first message only, with an admin-configurable option to update topic and sentiment from the latest end-user message. If nobody turned that option on, your &#8220;customer sentiment&#8221; field is a permanent record of how annoyed someone was in their opening paragraph.</p>



<p class="wp-block-paragraph">Think about what that does to a trend line. The opening message of a support ticket is negative almost by definition, because people do not open tickets to say things are going well. What you want to know is whether the interaction <em>ended</em> well. A ticket that opens furious and closes with a thank-you is a support win, and a first-message-only score records it as a loss forever.</p>



<p class="wp-block-paragraph">Two consequences follow that people miss:</p>



<ul class="wp-block-list">
<li>Your sentiment metric is structurally incapable of moving in response to anything the support team does. Hire better agents, cut response time in half, rewrite your macros: the number will not budge, because it was set before any of that happened.</li>

<li>It <em>will</em> move in response to marketing, onboarding and product changes upstream, because those change who opens tickets and how angry they are on arrival. So the metric drifts for reasons the team watching it cannot influence.</li>
</ul>



<p class="wp-block-paragraph">Zendesk also documents that when an agent corrects the sentiment field on a ticket, the correction does not retrain the model. Worth knowing before you build a human-review workflow on the assumption that it feeds back into anything. It gives you a cleaner column, not a better classifier.</p>



<p class="wp-block-paragraph">The fix is to decide your stamping policy explicitly and store it. Score the first inbound message, score the last inbound message, and store both, along with the delta. The delta is the interesting column. Sentiment recovery within a thread is a far better signal of support quality than absolute sentiment ever was.</p>



<h2 class="wp-block-heading">Failure two: averaging a scale that was never numeric</h2>



<p class="wp-block-paragraph">Zendesk exposes sentiment as five ordered values from Very negative to Very positive, and Zendesk Explore ships a prebuilt report for average customer sentiment on a one-to-five scale. Amazon Comprehend returns one of <code>POSITIVE</code>, <code>NEGATIVE</code>, <code>NEUTRAL</code> or <code>MIXED</code> plus per-class confidence scores. Google Cloud Natural Language returns a polarity score and a magnitude. Azure AI Language returns document- and sentence-level labels with confidences.</p>



<p class="wp-block-paragraph">Only some of those are numbers, and none of the label sets are interval scales. The gap between Very negative and Negative is not the same size as the gap between Neutral and Positive, and there is no defensible reason to assume it is. So when you compute <code>AVG(sentiment)</code>, you are doing arithmetic on rank labels and getting a value that has no units.</p>



<p class="wp-block-paragraph">That would be a pedantic complaint if it were only a purity issue. It is not, because support sentiment distributions are usually bimodal. People write in angry or they write in grateful. Very few tickets are genuinely neutral. Take the mean of a two-humped distribution and you land in the valley between the humps, in a bucket where almost nobody actually is. Worse, the mean is stable there: the humps can grow, shrink and swap size while the average barely moves. That is your flat 3.2.</p>



<p class="wp-block-paragraph">Use share metrics instead. They are ordinal-safe, they move, and they are directly actionable.</p>



<pre class="wp-block-code"><code>-- Share of negative tickets, weighted by account value.
-- Ordinal-safe: no arithmetic on the label itself, only counting.
SELECT
    date_trunc('week', t.created_at)                      AS week,
    COUNT(*)                                              AS tickets,
    COUNT(*) FILTER (
        WHERE t.sentiment IN ('very_negative', 'negative')
    )                                                     AS negative_tickets,
    ROUND(
        100.0 * COUNT(*) FILTER (
            WHERE t.sentiment IN ('very_negative', 'negative')
        ) / NULLIF(COUNT(*), 0)
    , 1)                                                  AS pct_negative,
    SUM(a.arr) FILTER (
        WHERE t.sentiment IN ('very_negative', 'negative')
    )                                                     AS arr_behind_negative
FROM   support_tickets  t
JOIN   crm_contacts     c ON c.id = t.contact_id
JOIN   crm_accounts     a ON a.id = c.account_id
WHERE  t.created_at &gt;= date_trunc('week', CURRENT_DATE) - INTERVAL '26 weeks'
GROUP  BY 1
ORDER  BY 1;</code></pre>



<p class="wp-block-paragraph">Three things are happening there worth calling out. <code>FILTER</code> counts rows in a category without touching the label as a number. <code>NULLIF</code> stops a zero-ticket week from throwing a division error and silently killing the job. And the last column is the one executives will actually read, because a percentage of tickets is a support metric while revenue sitting behind negative sentiment is a business metric.</p>



<h2 class="wp-block-heading">Failure three: the people who left never showed up in the data</h2>



<p class="wp-block-paragraph">Support data only contains customers who chose to contact you. That is a selection effect, not a sample.</p>



<p class="wp-block-paragraph">The dangerous version is this: negative sentiment falls, everyone celebrates, and the actual cause is that the angriest cohort stopped writing in because they had already decided to leave. Silence reads as improvement. In a quarter where churn is rising, a falling negative-sentiment share is a warning sign, not a win.</p>



<p class="wp-block-paragraph">You cannot fix this inside the support dataset, which is precisely why the CRM join matters. Two guards worth building:</p>



<ul class="wp-block-list">
<li><strong>Contact rate as a companion metric.</strong> Tickets per active account per month, tracked alongside sentiment. Sentiment improving while contact rate collapses is a disengagement pattern, not a satisfaction one.</li>

<li><strong>Went-quiet detection.</strong> Accounts that used to file tickets regularly and have filed none in a defined window. In most books of business this list is a better churn predictor than the sentiment score itself, and it costs one window function to produce.</li>
</ul>



<h2 class="wp-block-heading">Failure four: the model learned someone else&#8217;s language</h2>



<p class="wp-block-paragraph">General-purpose sentiment APIs are trained largely on review-style and social text. Support English is a different dialect, and the mismatches are systematic rather than random.</p>



<ul class="wp-block-list">
<li><strong>Problem statements are not complaints.</strong> &#8220;The export fails with a 500&#8221; is a factual bug report. Review-trained models frequently score it negative because failure vocabulary dominates.</li>

<li><strong>Politeness masks severity.</strong> A calmly worded message from an enterprise admin saying the production sync has been down since Tuesday is your worst ticket of the week and will score close to neutral.</li>

<li><strong>Domain vocabulary inverts.</strong> &#8220;Killed the process&#8221;, &#8220;the job died&#8221;, &#8220;aborted the run&#8221; are neutral technical descriptions carrying heavy negative weight in general corpora.</li>

<li><strong>Your own text pollutes the corpus.</strong> Auto-acknowledgements, signatures, quoted reply chains and CSAT survey footers all get scored if you feed the raw comment body. Quoted history is the big one: score a five-reply thread naively and you have scored message one five times.</li>

<li><strong>Non-native English reads flat.</strong> Simpler sentence structure and less emotional vocabulary push scores toward neutral for a whole segment of your customer base, which quietly biases any regional comparison.</li>
</ul>



<p class="wp-block-paragraph">Independent evaluations of the major cloud sentiment APIs on general text tend to land well below the accuracy figures in vendor marketing, and the providers frequently disagree with each other on the same input. Treat published benchmark numbers as irrelevant to your decision. The only number that matters is accuracy on a few hundred of your own tickets, hand-labelled by someone who understands your product.</p>



<h3 class="wp-block-heading">Building the evaluation set</h3>



<ol class="wp-block-list">
<li>Pull a stratified random sample of tickets: across channels, across regions, across account tiers. Do not sample only recent tickets, or you bake in whatever was happening that month.</li>

<li>Have two people label independently, using a written rubric with worked examples of each class.</li>

<li>Measure agreement between the humans first. If your two support leads disagree on a third of the tickets, the task is underspecified and no model will fix that. Tighten the rubric and relabel.</li>

<li>Only then score the candidate models against the human labels, and look at the confusion matrix rather than accuracy. Where a model confuses classes tells you whether it is usable.</li>

<li>Freeze that set. It becomes your regression test when a vendor silently updates a model version.</li>
</ol>



<p class="wp-block-paragraph">Step three is the one teams skip, and it is the one that saves the project. Human agreement is the ceiling on model performance. There is no point chasing 90% against labels your own experts only agree on 70% of the time.</p>



<h2 class="wp-block-heading">Failure five: the CRM join is where the value and the breakage both live</h2>



<p class="wp-block-paragraph">A sentiment label with no account attached is trivia. Two hundred irritated free-tier users and one irritated account paying for a third of your renewals are not the same event, and the raw ticket table cannot tell them apart.</p>



<p class="wp-block-paragraph">The join from helpdesk to CRM is almost always on email, and email is a terrible key. Expect all of this:</p>



<ul class="wp-block-list">
<li>Shared inboxes such as ops@ or billing@, mapping to no single contact or to several.</li>

<li>Personal addresses used by people who work at a customer, invisible to any domain-matching logic.</li>

<li>Contacts who left and were deleted in Salesforce, HubSpot or Zoho CRM, orphaning every historical ticket they filed.</li>

<li>Account hierarchies, where the subsidiary files the ticket and the parent holds the contract.</li>

<li>Mergers and renames, which quietly rewrite history if your warehouse dimension is not versioned.</li>
</ul>



<p class="wp-block-paragraph">The engineering answer is boring and effective: treat unmatched tickets as a first-class, monitored quantity rather than something the join silently discards. Track match rate as a data quality metric with an alert threshold. When it drops, something changed in the CRM, and you want to know that before the number on the dashboard shifts for a reason nobody can explain.</p>



<p class="wp-block-paragraph">Also version the account dimension. If you overwrite the tier or the ARR in place, then every historical aggregate silently rewrites itself the next time someone upgrades, and last quarter&#8217;s report stops reproducing.</p>



<h2 class="wp-block-heading">The legal boundary that catches sentiment projects</h2>



<p class="wp-block-paragraph">This one is worth reading carefully if you have EU customers or EU staff, because the line does not fall where most people assume, and a lot of contact-centre software sells both sides of it in one bundle.</p>



<p class="wp-block-paragraph">Article 5(1)(f) of the EU AI Act prohibits AI systems that infer emotions of a person in the workplace or in education settings. The prohibitions in Article 5 have applied since 2 February 2025, and breaches sit in the Act&#8217;s highest penalty band. What matters for a sentiment project is the scope, which is narrow on two axes at once:</p>



<ul class="wp-block-list">
<li>It applies to emotion inference <strong>from biometric data</strong> — faces, voiceprints, physiological signals. The European Commission&#8217;s guidance takes the position that inferring emotion from written text is not based on biometric data and therefore falls outside the prohibition.</li>

<li>It applies to <strong>workplace and education contexts</strong>. Your customers writing into a helpdesk are neither.</li>
</ul>



<p class="wp-block-paragraph">So scoring the text of customer tickets and CRM notes generally sits outside the ban. Running voice-based emotion or stress analysis on your own support agents&#8217; calls, for coaching or quality scoring, is the case the prohibition was written for. Plenty of platforms ship customer-facing text sentiment and agent-facing voice sentiment as adjacent features in the same product, and the pitch does not distinguish them. If you are the deployer, the compliance obligation is yours, and a vendor&#8217;s assurance is not a defence.</p>



<p class="wp-block-paragraph">Separately, Article 50 carries transparency obligations for emotion recognition systems where they remain lawful. And none of this displaces GDPR: ticket bodies are full of personal data, and shipping them to a third-party API is a processing decision that needs a basis, a processor agreement and a residency answer.</p>



<p class="wp-block-paragraph">I am an engineer, not a lawyer. Treat the above as the shape of the question to take to counsel, not as advice.</p>



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



<h2 class="wp-block-heading">A pipeline shape that survives contact</h2>



<p class="wp-block-paragraph">Nothing exotic. The discipline is in what you store, not what you call.</p>



<ol class="wp-block-list">
<li><strong>Extract incrementally</strong> from the helpdesk and CRM on an updated-at cursor, not a full reload. Land raw payloads immutably before you transform anything, so a bad model run is re-runnable without re-hitting the source API.</li>

<li><strong>Clean the text.</strong> Strip quoted reply chains, signatures, automated acknowledgements and survey footers. Keep the cleaned text as its own column so a scoring bug is debuggable later.</li>

<li><strong>Redact personal data</strong> before it leaves your boundary. Regex handles the structured patterns: emails, phone numbers, card and account numbers, order IDs. Named-entity detection handles names and addresses. Amazon Comprehend exposes <code>DetectPiiEntities</code> and <code>ContainsPiiEntities</code> for this if you are already in AWS.</li>

<li><strong>Score in batches</strong>, with the model identifier and version written into every row.</li>

<li><strong>Aggregate at account level</strong> with share metrics and value weighting, against a versioned account dimension.</li>

<li><strong>Alert on movement, not on level.</strong> Nobody acts on &#8220;sentiment is 3.2&#8221;. People act on &#8220;this named account&#8217;s negative share tripled in two weeks&#8221;.</li>
</ol>



<p class="wp-block-paragraph">Step four&#8217;s version column is the one people leave out and regret. Managed sentiment endpoints get retrained. When labels shift on unchanged text, the version column is the difference between a five-minute explanation and a week of forensics.</p>



<p class="wp-block-paragraph">Here is what a batch call looks like against Amazon Comprehend, which is a reasonable default if your warehouse already lives in AWS:</p>



<pre class="wp-block-code"><code>import boto3

comprehend = boto3.client("comprehend", region_name="eu-west-1")

# BatchDetectSentiment accepts a maximum of 25 documents per call,
# and DetectSentiment caps each document at 5 KB of UTF-8 text.
# Chunk to both limits or the call fails for the whole batch.
BATCH_SIZE = 25
MAX_BYTES  = 5000

def score_batch(texts):
    payload = [t.encode("utf-8")[:MAX_BYTES].decode("utf-8", "ignore")
               for t in texts[:BATCH_SIZE]]
    resp = comprehend.batch_detect_sentiment(
        TextList=payload,
        LanguageCode="en",
    )
    # ResultList is index-aligned to the input; ErrorList holds
    # per-document failures. Read both or you lose rows silently.
    return resp["ResultList"], resp["ErrorList"]</code></pre>



<p class="wp-block-paragraph">The truncation on the encoded bytes rather than the character count is deliberate: the limit is measured in bytes, and a ticket full of accented characters or CJK text hits it much earlier than the character count suggests. The <code>ErrorList</code> read matters for the same reason a dead letter queue matters. A partial batch success that you never inspect is data loss that looks like a clean run.</p>



<p class="wp-block-paragraph">For a quick sanity check on a single string before wiring any of this up, the CLI is faster than writing code:</p>



<pre class="wp-block-code"><code>aws comprehend detect-sentiment 
    --region eu-west-1 
    --language-code "en" 
    --text "Still waiting on the refund you promised last week."

{
    "SentimentScore": {
        "Mixed": 0.0033542951568961143,
        "Positive": 0.0086313202530145600,
        "Neutral": 0.1495875907897949,
        "Negative": 0.8384268 
    },
    "Sentiment": "NEGATIVE",
    "LanguageCode": "en"
}</code></pre>



<p class="wp-block-paragraph">Note that the response carries per-class confidences, not just the winning label. Store the whole <code>SentimentScore</code> object. A ticket scored NEGATIVE at 0.51 and one scored NEGATIVE at 0.98 are very different tickets, and the label alone throws that away. Confidence is also what lets you route only the ambiguous cases to a more expensive model or a human, instead of paying for everything.</p>



<h3 class="wp-block-heading">Choosing where the model runs</h3>



<p class="wp-block-paragraph">Three broad options, and the trade-offs are real in both directions.</p>



<ul class="wp-block-list">
<li><strong>Native helpdesk features.</strong> Zendesk intelligent triage, Salesforce Einstein, HubSpot and Zoho CRM all ship some form of built-in sentiment. Zero integration work, labels land on the ticket where agents can see them, and routing rules can consume them directly. You get the vendor&#8217;s definition of sentiment, no control over stamping policy beyond the toggles they expose, and data that is awkward to move.</li>

<li><strong>Managed NLP APIs.</strong> Amazon Comprehend, Azure AI Language and Google Cloud Natural Language. Per-character billing, no infrastructure, useful extras such as aspect-level or targeted sentiment. Comprehend&#8217;s targeted sentiment, which attaches sentiment to specific entities in the text, is English-only, and that constraint bites hard if your support volume is multilingual. Bill by volume of text, so a batch backfill of five years of tickets is a genuinely different cost event from steady-state scoring.</li>

<li><strong>Self-hosted transformers.</strong> A fine-tuned encoder model from the Hugging Face ecosystem, running on your own box. Best accuracy on your domain once tuned, no per-call cost, no text leaving your perimeter, which resolves most residency arguments before they start. In exchange you own model serving, evaluation and drift. A mid-tier dedicated server from a provider like Contabo or Hetzner runs CPU inference for batch workloads perfectly well, and batch is what this workload is. You do not need a GPU to score yesterday&#8217;s tickets overnight.</li>
</ul>



<p class="wp-block-paragraph">If you have no strong residency constraint and a small volume, start with a managed API. The self-hosted path is worth it when you have enough labelled data to fine-tune, or a legal reason the text cannot leave. Not before.</p>



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



<h3 class="wp-block-heading">Sentiment is flat regardless of what the team does</h3>



<p class="wp-block-paragraph">Two likely causes. Either you are averaging an ordinal scale over a bimodal distribution, or you are scoring first messages only. Check the distribution shape first, since it takes one query. If it has two humps, switch to share metrics before you touch anything else.</p>



<h3 class="wp-block-heading">Everything scores neutral</h3>



<p class="wp-block-paragraph">Usually a text-cleaning problem. If quoted reply chains, signatures and boilerplate survive into the scored text, the actual message is diluted by noise and the model regresses to neutral. Print the exact string you are sending for twenty tickets and read them. This bug is invisible in aggregate and obvious in the raw payload.</p>



<h3 class="wp-block-heading">The trend shifted overnight with no matching business event</h3>



<p class="wp-block-paragraph">Check the model version column and the CRM match rate on the same day. A managed endpoint retrain and a CRM field change both produce exactly this signature. If you did not store the version, this is the moment you start.</p>



<h3 class="wp-block-heading">One region always looks happier</h3>



<p class="wp-block-paragraph">Before concluding anything about that region, check the language distribution. Non-native English and machine-translated tickets both drift toward neutral, which shows up as &#8220;less negative&#8221; in a share metric. Compare like with like by language, or score in the original language where the provider supports it.</p>



<h3 class="wp-block-heading">Batch scoring jobs fail intermittently</h3>



<p class="wp-block-paragraph">Almost always the document size limit, hit by one oversized ticket with a pasted log file or stack trace in the body. Truncate on encoded byte length before the call and check the per-document error list rather than assuming a successful HTTP response means every document scored.</p>



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



<ul class="wp-block-list">
<li>Reporting an average of an ordinal label set and calling it a score.</li>

<li>Scoring the first message and describing the result as customer satisfaction.</li>

<li>Treating unmatched tickets as an inner-join casualty instead of a monitored metric.</li>

<li>Sending raw ticket bodies to a third-party API with no redaction step.</li>

<li>Discarding confidence scores and keeping only the winning label.</li>

<li>Trusting a vendor accuracy figure over a few hundred of your own hand-labelled tickets.</li>

<li>Overwriting the account dimension in place, so historical reports quietly stop reproducing.</li>

<li>Assuming a helpdesk vendor&#8217;s compliance posture transfers to you as the deployer.</li>
</ul>



<h2 class="wp-block-heading">Best practices for customer sentiment analysis</h2>



<ul class="wp-block-list">
<li>Write down the unit of analysis before writing any code, and make sure the dashboard label matches it.</li>

<li>Store first-message sentiment, last-message sentiment and the delta. Report the delta.</li>

<li>Use share-of-negative and value-weighted share, never a mean over labels.</li>

<li>Keep a frozen, hand-labelled evaluation set and re-run it on a schedule as a regression test.</li>

<li>Persist the model identifier, model version and full confidence distribution on every scored row.</li>

<li>Redact personal data before the text crosses a network boundary, and log what was redacted, not the values.</li>

<li>Monitor CRM match rate, contact rate and language mix alongside the sentiment number itself.</li>

<li>Alert on named-account movement, not on the global level.</li>

<li>Know which side of the biometric line each feature you enable sits on, and put the answer in writing.</li>
</ul>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">Is customer sentiment analysis accurate enough to act on?</h3>



<p class="wp-block-paragraph">For prioritisation and trend detection, generally yes. For individual decisions about a specific customer, no. The reliable pattern is to use it as a filter that surfaces conversations for a human to read, rather than as a verdict. Any single ticket&#8217;s label should be treated as a hint, not a fact.</p>



<h3 class="wp-block-heading">Should I use my helpdesk&#8217;s built-in sentiment or build my own pipeline?</h3>



<p class="wp-block-paragraph">Start with the built-in feature. It is free or cheap, it lands where agents work, and it tells you within a month whether anyone actually uses sentiment for anything. Build your own when you need a different stamping policy, cross-system aggregation, account-value weighting, or control over where the text is processed. Those are the four reasons that justify the effort.</p>



<h3 class="wp-block-heading">Can I use an LLM instead of a dedicated sentiment API?</h3>



<p class="wp-block-paragraph">You can, and the quality on nuance, sarcasm and domain vocabulary is usually better because you can describe your product and your customers in the prompt. The costs are latency, price per token at backfill scale, and output stability. Pin the model version, constrain the output to a fixed label set, and set temperature to zero, or you will get labels that drift between runs on identical text. A pragmatic middle ground is a cheap classifier by default with an LLM as a fallback on low-confidence cases.</p>



<h3 class="wp-block-heading">How much historical data do I need before the trend means anything?</h3>



<p class="wp-block-paragraph">Enough to cover your seasonality, which for most businesses means at least a full year. Support volume and tone move with billing cycles, product releases, holidays and renewal periods. A quarter of data will show you a trend that is really just the calendar.</p>



<h3 class="wp-block-heading">Does sentiment analysis on support tickets fall under the EU AI Act ban?</h3>



<p class="wp-block-paragraph">The Article 5(1)(f) prohibition targets emotion inference from biometric data in workplace and education contexts, and Commission guidance treats text-based sentiment as outside that scope. Scoring customer-written text is generally not caught. Voice or facial emotion analysis applied to your own employees is squarely the thing it prohibits. Transparency duties and GDPR obligations still apply regardless, and this is a question for your counsel rather than your engineer.</p>



<h3 class="wp-block-heading">What is a good target for share of negative tickets?</h3>



<p class="wp-block-paragraph">There is no cross-industry benchmark worth quoting, because the number depends entirely on your product category, your support channels and how the model was calibrated. Your own baseline is the only meaningful comparison. Measure for a full cycle, then set targets against that.</p>



<h3 class="wp-block-heading">How do I handle multilingual support volume?</h3>



<p class="wp-block-paragraph">Detect the language, store it as a column, and segment every report by it. Do not translate to English and then score, since translation flattens emotional register and adds a second error source. Check which of your provider&#8217;s features are English-only before designing around them; some advanced modes, including Comprehend&#8217;s targeted sentiment, are narrower than the base sentiment endpoint.</p>



<h2 class="wp-block-heading">The one thing worth remembering</h2>



<p class="wp-block-paragraph">A customer sentiment analysis pipeline that runs cleanly every night and produces a number nobody can act on is not a partially working system. It is a broken one, and the breakage is upstream of anything your monitoring can see.</p>



<p class="wp-block-paragraph">So before optimising a model or shopping for a platform, answer three questions in writing: which message gets scored, what unit the number describes, and which accounts it is weighted by. Get those right with a mediocre classifier and you have something useful. Get them wrong with the best model on the market and you have a very accurate description of how annoyed people were in their opening sentence.</p>



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



<h2 class="wp-block-heading">Need help making this work on your data?</h2>



<p class="wp-block-paragraph">I build and fix the data plumbing behind customer analytics. On this particular problem, that usually means:</p>



<ul class="wp-block-list">
<li>Auditing an existing sentiment metric to find out why it never moves, and rebuilding it on share and delta metrics that do.</li>

<li>Building incremental extraction from Zendesk, Salesforce, HubSpot or Zoho CRM into a warehouse, with cursor state, retries and dead-letter handling that survives an API outage.</li>

<li>Fixing the helpdesk-to-CRM identity join, including shared inboxes, account hierarchies and a versioned account dimension so historical reports keep reproducing.</li>

<li>Standing up a redaction layer so ticket text is scrubbed of personal data before it reaches any third-party model endpoint.</li>

<li>Running a proper evaluation: stratified sample, dual human labelling, inter-rater agreement, confusion matrices across candidate models, and a frozen regression set.</li>

<li>Setting up account-level alerting in Grafana or your BI tool that fires on movement in named accounts rather than on a global average.</li>
</ul>



<p class="wp-block-paragraph">If you have a dashboard that has been flat for months, send me the query behind it and a screenshot of the distribution. That is usually enough to tell you what is wrong before we talk about scope.</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/technical-guides/customer-sentiment-analysis-crm-support-data/">Customer Sentiment Analysis From CRM and Support Data: Building a Score You Can Actually Trust</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
	</channel>
</rss>
