Warehouse sync
Warehouse sync drops your calls, conversions, attribution and pipeline into files you load into your own data warehouse. That is the point: once the data is in BigQuery, Snowflake, Redshift or anything else you use, you can join it to everything else you hold — subscription revenue, support tickets, your sales team's activity — which is a join only you can do.
Two different things have to be true, and they have two different fixes.
Warehouse export is a plan feature — it is included from Scale
upwards on a single-company plan, and on every agency tier, and no role on a lower plan can
switch it on. Separately, creating a destination needs the
data-export permission, which an administrator grants to a role — reading the
list of destinations only needs the plainer integration:read. A Scale account
where nobody has been granted data-export sees the screen and cannot add a destination; a
Starter account sees the plan's own refusal no matter who is asking. If you are told your role
is the problem, check which of the two it actually is — the message now says.
Load with a MERGE on row_key, never a bare
INSERT. This is the one that costs people an afternoon, and it costs it
quietly: every file is re-runnable by design, so an INSERT works perfectly the
first time and doubles your revenue table the second. Every row carries
row_key and row_version for exactly this, and the load statements are
generated for you — see below.
Setting one up
Under Data export, add a destination: give it a name, choose which datasets you want, and copy the signing secret. The secret is shown once. That is deliberate — it means nobody who gets into your account later can read it either. If you lose it, rotate it, which invalidates any URL already sitting in a scheduler.
A destination produces a run on a schedule you set, defaulting to daily. Each run
writes one CSV per dataset plus a manifest.json, and you fetch them with signed URLs.
Why you pull rather than us pushing
We do not hold a credential for your warehouse. There is no service-account key of yours in our database, so a breach of ours cannot reach your data platform. Instead you fetch the files with a URL we sign, which reaches exactly one file of one run and stops working within hours.
The trade-off is real and worth saying out loud: you schedule the fetch, so a broken pipeline on your side looks like missing data from us. If your table stops updating, check your loader before you check with us — and the run list in the dashboard shows whether a run was produced and whether anything ever downloaded it.
Signed URLs
Press Get download links on a run and you get one URL per file. Each looks like:
https://api.proofbell.com/v1/warehouse/exports/wxr_…/files/calls.csv?expires=…&signature=v1:… - They expire. One hour by default, seven days at most. A permanent URL to your bulk call data is a credential that cannot be rotated without somebody noticing, which is the state in which it never is.
- The expiry is inside the signature. Editing it in the URL does not extend it — the request is refused.
-
One signature fetches one file. A link for
conversions.csvwill not fetchcalls.csv, so you can give a pipeline only the dataset it needs. -
A wrong or expired signature answers
404, not403. That is on purpose: a distinct answer would let anyone with a run id confirm it exists.
The exception is a URL whose signature is valid but past its expiry — that answers
403 with a plain message, because anyone holding a valid signature has already proved
they have the secret, and telling them the link is stale saves the afternoon otherwise spent
regenerating one that was correct all along.
Loading it, without duplicating rows
Data export → your destination → Load statements generates the exact
CREATE TABLE and MERGE for your destination's current shape. Generated
rather than printed here, because the shape depends on your settings and a copied statement from a
documentation page would be for somebody else's.
Do not load an export with schema autodetect. This is the one instruction here that will cost you money if you skip it. Autodetect sees a column of digits and is entitled to choose a floating-point type for it — and a money column stored as a float loses pennies on large amounts, silently, in every table and every chart built on top of it. Nothing errors and nothing looks wrong; the totals simply stop agreeing with your invoices by amounts too small to notice and too large to ignore once somebody does.
The generated statement declares every money column as INT64 for exactly that reason. It
is also why a window in which a money column happens to be empty for every row is dangerous under
autodetect: with nothing to infer from, the column can arrive as STRING, and the first
real value then fails to load rather than rounding — the better of the two failures, but only
because it is loud.
Money is in minor units. Every _minor column is whole pence, never
pounds — 4500 is £45.00. The column descriptions in the generated schema say
so and they survive into your warehouse's schema view. Charting one as currency without dividing is
out by a factor of a hundred.
The contract, which is the same for every dataset:
-
row_keyis unique per logical fact and stable for ever. Re-exporting the same fact produces the same key. -
row_versionis a non-decreasing integer for that key. Only overwrite a stored row when the incoming version is greater than or equal to the stored one — two runs can be in flight at once, so an older file must not move a figure backwards. -
exported_atis when the file was made. It is not a business date, and reporting on it produces a chart of when our scheduler ran.
Load into a staging table, then merge:
MERGE proofbell.conversions T
USING (
-- Newest version wins, in case the staging load holds a re-export.
SELECT * EXCEPT(_rank) FROM (
SELECT *, ROW_NUMBER() OVER (
PARTITION BY row_key ORDER BY row_version DESC, exported_at DESC
) AS _rank FROM proofbell.stg_conversions
) WHERE _rank = 1
) S
ON T.row_key = S.row_key
WHEN MATCHED AND S.row_version >= T.row_version THEN UPDATE SET …
WHEN NOT MATCHED THEN INSERT …
The types in the generated statements are words Snowflake accepts too, so they are close to
portable. What is not portable is the ROW_NUMBER subquery above — Snowflake
writes that as QUALIFY.
Attribution needs a delete first
attribution.csv is the one dataset where a bare MERGE is not enough. Its
key is conversion + model + channel, and the channels credited for a conversion can
change — a session that arrived late can shift credit from one channel to another. The old
channel's row is not in the new file, so a merge leaves it behind and the conversion appears to have
been credited twice. Delete the conversions present in the file, then insert:
DELETE FROM proofbell.attribution
WHERE conversion_id IN (SELECT DISTINCT conversion_id FROM proofbell.stg_attribution);
INSERT INTO proofbell.attribution SELECT * FROM proofbell.stg_attribution; Money is an integer of minor units
Every column ending _minor is a whole number of the smallest unit of the currency in
the currency column beside it. 402350 with GBP is £4,023.50.
Store the integer and divide in your reporting layer, not on the way in. The whole reason these files are CSV rather than JSON is that a JSON number becomes a floating-point double in every mainstream parser — including BigQuery's own JSON ingestion — and a large value loses its last digits with nothing erroring. Dividing by 100 before you store reintroduces exactly the problem the format avoids, and the symptom is a report that disagrees with your accounts by pennies per row.
A corrected deal changes a past period
If a deal closes in March for £10,000 and someone corrects it to £4,000 in July, March's figure becomes £4,000. The deal does not move to July. That matches what Google, Microsoft and Meta all do with an adjustment, and it is the only behaviour under which "what did January's spend produce" has one answer rather than one per month in which somebody happened to look.
What that means for your load: a row can change months after its occurred_at. The
correction arrives as the same row_key at a higher row_version, so the
MERGE is what keeps your table agreeing with ours. A window narrow enough to miss it
would leave your warehouse holding the old figure indefinitely, which is why the default export
window is 90 days rather than a week.
The datasets
| Dataset | One row per | Keyed on |
|---|---|---|
calls | Tracked call | Call id. Talk time, billability and disposition settle after the call, so a re-export corrects them. |
conversions | Conversion | Conversion id, versioned by value_generation — a restated value arrives at a
higher version. |
attribution | Conversion × model × channel | All three. Not additive across models — always
GROUP BY model. |
opportunities | CRM deal | Opportunity id, versioned by generation. |
Reading the conversions dataset
offline_source carries where a lead came from when you told us: one of
tradeshow, webinar, sdr_outbound, instore,
lead_form or sales_call. It comes from the lead source column of a CRM
import — see importing deals.
Empty means two different things, and conversion_type tells them apart.
Empty on an offline row means you stated no source, or stated one we would not place.
Empty on a call or form row means the question does not apply. Treating
both as a single “unknown” bucket buries your trade-show leads under every phone call
you took, so filter on conversion_type = 'offline' before counting them.
The conversions dataset gained this column on 26 August 2026. If you have an
existing table, add it before your next load — ALTER TABLE … ADD COLUMN
offline_source STRING — or a strict loader will reject the file. It is the
last column deliberately, so a loader matching positionally does not shift anything
that was already there. The manifest's columns_fingerprint for
conversions changed with it, which is what you can key an automated check on.
Reading the attribution dataset
It carries two models — last non-direct (what the dashboard shows) and linear — because they disagree by construction and one number presented as truth is what makes attribution distrusted. Sum within a model, never across.
Two columns are worth understanding before you build anything on it:
-
anchorisoriginwhen the lookback window was measured back from the call that started the deal, andconversionwhen it was measured from the conversion's own date. It matters more than it sounds: a deal that took six months to close has its call outside a 90-day window measured from the close, so anchoring at the close loses its marketing entirely — with nothing erroring. Where we can identify the originating call, we anchor there andanchor_attells you which date did the work. -
unattributed_reasonseparatesno_touchpoints(we never saw this person click) fromoutside_lookback(we did, but before the window). They need different fixes: the first is a caller who never visited the site, the second means your lookback is too short.
attribution_degraded in the calls dataset
true means every tracking number was in use when that visitor arrived, so they saw a
shared one. The call is real; the campaign beside it is a best guess. Filter on it before drawing a
conclusion from a small difference between two channels.
What is never in an export
- Recordings and transcripts. Not as text, not as a URL, and not even a column saying one exists. A recording is a member of the public's voice; it is fetched from the API by a named person holding a permission, with an audit entry, and a file drop is the exact opposite of that.
- Hashed email and phone. This is the exclusion people query, because a hash reads as a pseudonym. For a phone number it is not one — there are fewer than a quadrillion possible numbers, so a hash of one is reversible by exhaustive search on a laptop. Exporting it would be exporting the number while appearing not to.
- The caller's number, unless you switch it on for that destination. Most joins do not need it, and we would rather the most sensitive column not travel to a destination that has no use for it.
Switching the caller's number on changes the file shape. A new column is added,
so add it to your table before the next run — on some loaders a mismatched column count
fails the load, and on others it succeeds while shifting every column after it. The manifest's
columns_fingerprint changes whenever the shape does, so you can check for it rather
than find out. And the obvious point: the number is personal data under UK GDPR, and your
warehouse becomes a processor of it.
The manifest
manifest.json is worth reading before the CSVs. It carries the declared type of every
column, so your loader never has to guess — the guess is the failure mode, and a window in which a
money column happens to be empty for every row is inferred as text, which breaks the next load.
It also carries content_hash per file, so you can skip a file you have already loaded,
and it states plainly what the export excludes.
Retention, and a limit
Export files stay fetchable for 14 days, then we delete them. Your warehouse is the durable copy by design — a bulk copy of your call data sitting on our storage indefinitely is a liability with no owner. Trigger a new run if you need one again.
There is a ceiling of 200,000 rows per dataset per run, and a run that exceeds it refuses and writes nothing rather than truncating. That is deliberate: a truncated file looks complete in your warehouse and is missing the oldest rows, so every period comparison ends up quietly wrong in the direction that flatters recent performance. If you hit it, narrow the window on the destination and run it more often.
If you open a CSV in a spreadsheet
Campaign names come from URL parameters on your own site, so they are whatever somebody typed. A
value starting =, +, - or @ is treated as a
formula by Excel and Google Sheets. We do not alter the value — the destination is a
warehouse and changing a campaign name would break your join — but the manifest names any column in
the file that contains such a value under spreadsheet_unsafe_columns. Import as text
if you are opening one by hand.