⚙️ This is a record of one real run, not a specification. The numbers are what happened on one app. Read the mechanisms as durable and the figures as an account.
04 — Data in and out describes the shape: model where the analyst already works, land the output by webhook, govern it with seats. This is that story with the numbers left in, including the parts that went wrong.
The situation
A design workspace held 262 activity records. The deployed client app held 0. Same entities, same pages — and one of those pages carried an Activities table that had been empty since the day it was built.
Nothing was broken. Seeds run on a virgin install only: a redeploy never re-seeds, duplicates or overwrites end-user records. Configuration ships on every deploy; data does not. That is the correct behaviour, and its consequence is that data added to a workspace after the first deploy stays in the workspace.
⚙️ The same is true of people. A person the deploy has not seen before is created, so a seat added in Build gets a working login. A person who is already in the app is left alone — their name, their role and their password belong to the running app, and a deploy does not touch them. Change those in the app’s own Users screen.
First question: would those rows even be correct there?
Not “how do I copy them” — “do they mean anything over there?”
The activities reference deal and rep by id. Measured against the deployed app:
deal references resolving in the app : 0 / 187
rep references resolving in the app : 0 / 5
Every one would have dangled. Copying verbatim would have put 262 rows in front of the client each pointing at nothing — a populated table that is wrong, which is worse than the empty one it replaced. An empty state says “no data”. A broken join says nothing at all, and looks fine doing it.
Why re-pointing was safe
The two sides turned out to hold the same data, generated independently:
rep names identical : 12 / 12 ids shared : 0
deal names identical : 190 / 190 ids shared : 0
So the question became whether a natural key could identify a deal on both sides. name
alone could not — 345 rows across 190 names. But:
app deals : 343 rows, 343 distinct (name + closeDate + dealValue), 0 duplicates
studio deals : 345 rows, all 345 matched an app key
That makes the mapping an identity lookup, not a guess — and the distinction is the whole
decision. Reps mapped on fullName. Result: 262 / 262 activities mappable.
⚠️ One lossy edge, measured rather than waved away: two studio deals shared a key and collapse onto a single app deal. Zero of the 262 activities touched them, so it moved nothing. Had it been thirty, that would have been a decision to take deliberately, not a footnote.
The upsert key that was not a key
An inbound endpoint upserts on a key you choose, so a job that re-sends its whole output updates
rows instead of doubling them. The obvious key here is (deal, rep, occurredAt) — who did what,
when.
(deal, rep, occurredAt) → 188 / 262 distinct
(deal, rep, occurredAt, note, durationMinutes) → 262 / 262 distinct
It is not unique. Using it would have silently collapsed 74 rows on the first re-run — losing a quarter of the data, quietly, inside the mechanism whose entire purpose is preventing exactly that.
⚙️ Count your key against the source before you send anything. If the number of distinct tuples is not the number of rows, it is not a key. This takes one query and it is the difference between an idempotent pipe and a lossy one.
The delivery
Endpoint created with a field mapping and that upsert key, request HMAC-signed, sent in batches:
batch 1: 202 recordsLoaded 100
batch 2: 202 recordsLoaded 100
batch 3: 202 recordsLoaded 62
───
262
Two mistakes on the way, both easy to repeat:
⚠️ The receiver URL was constructed instead of read. /hooks/<tenant>/<path> looked right
and produced three 404s. The create response returns a receiverUrl and the real prefix is
/webhooks/inbound/. The endpoint tells you where it lives — ask it rather than inferring it
from the shape you expect.
⚠️ A reference needs a business key, not an id. A model — or any system that pushes — knows its own identifiers, never the ones your app generated. Map the reference field to the key the sender actually has (“the Deal whose Reference is this”) rather than expecting a UUID. A key that matches nothing is refused, and the refusal now names which key and which entity.
⚠️ The signature went in the wrong header — 401 signature_invalid: missing_header. Each
scheme names its own header; hmac_sha256 uses x-signature.
Choosing a scheme
| scheme | header | signs | pick it when |
|---|---|---|---|
hmac_sha256 | x-signature | the raw body | a generic system that can HMAC |
github | x-hub-signature-256 | the raw body | GitHub |
stripe | x-stripe-signature | timestamp.body | Stripe |
ttp | x-ttp-signature | timestamp.body | another TTP app is calling in |
none | — | — | a source that genuinely cannot sign |
★ ttp is the one to pick when the caller is one of your own deployed apps. TTP’s outbound
webhooks sign that way, and the inbound scheme matches them — so an app calling
another app was refused, while an outside system signing the documented way went through. The
platform could not verify its own webhooks.
⚙️ ttp refuses a replay outright, which the others cannot. It requires a fresh
x-ttp-timestamp (five-minute window), and a missing or stale one is a rejection rather than a
shrug. hmac_sha256 has to accept a captured-and-resent body because an arbitrary caller may
have no clock to agree on; when the sender is TTP, that excuse does not apply.
⚠️ Both ends must be given the same secret, by hand. There is no exchange between a sending subscription and a receiving endpoint — read the endpoint’s secret, then create the subscription with that exact value. A mismatch reads as a broken integration rather than a copy-paste error.
Then the check that makes it a pipe rather than a one-off: the whole load was run a second time. Still 262, not 524.
Verify by effect, on a real seat
activities in the app : 262
dangling deal/rep references : 0
Then the check that counts: sign in as an ordinary user, not as an admin. An admin never
consults the permission matrix — the access decision short-circuits before entity permissions are
read at all — so an admin’s green screen proves nothing about what a client’s staff will see.
Signed in on an executive seat, the Activities table rendered rows.
⚠️ And that seat check found a defect the row count could not. The table showed data, and two of its columns showed raw ids, because reference columns were rendering the id instead of the referenced record’s name. “The widget shows data” and “the widget is readable” are two different claims, and only one of them was true. The load was correct; the screen was not usable until that was fixed separately.
Last: look at what you are about to send
Two of the 262 were test rows left in the workspace by an earlier experiment — notes reading
MODEL-OUT scenario A briefing. They rode along with everything else into the client’s app and
had to be removed from both sides afterwards.
⚙️ A design workspace accumulates scratch data the way any working file does. The rows you are about to put in front of a client deserve one look before they go.
What to take from it
| Before copying rows between instances | ask whether their references resolve there, not whether the rows are valid |
| Before choosing an upsert key | count distinct tuples in the source; the obvious key is often not one |
| Before trusting the endpoint | read the receiver URL it returns; do not build it |
| Before calling it done | load the page on a non-admin seat — an admin bypasses the matrix entirely |
| Before shipping the data | check the source for your own scratch rows |