Skip to main content
A data store holds one kind of business record — the phone numbers your company owns, for instance — and keeps every source that describes it side by side rather than letting the newest writer overwrite the rest. Each source writes its own layer; the store computes a single resolved view from those layers using a precedence order you configure. That is the difference between a store and a sync. A sync gives you one system’s copy of the truth, and the last import wins. A store lets a nightly platform report, a spreadsheet you uploaded last month and a hand edit all be right about different fields of the same number, and lets you ask which one supplied any value you are looking at.

What the platform declares, and what you configure

A store’s shape is a platform declaration: its fields and their types, which field is the record key, the complete set of sources that may ever write to it, how long records and history are kept, and the events it emits. GET /data/stores/{store} returns all of it, so a table or a form can be rendered from that one response. Precedence is the part you configure. It can reorder or restrict the sources the store declares — it can never introduce a source or a field the store does not declare. Two stores ship today: Both are reachable on exactly the same routes — a store is a declaration, not an endpoint of its own. Contact phone numbers is declared and empty today: its fields, sources and precedence are live and GET /data/stores/contact-phone-numbers returns them, but no writer fills it yet, so its record list is empty. The columns are there from day one so the shape does not move under you when they do.

The record key

The key is a natural key, normalized by the store rather than by each writer. For phone numbers that is E.164 — +15551234567. Normalization happens on write, at the store boundary, and it is the reason one number never becomes two records. A spreadsheet uploads 5551234567, a platform report returns +15551234567, a hand edit types (555) 123-4567; all three land on the same record. Reads normalize too, so you can fetch a record by whichever form you have.
A value that is not a valid phone number is rejected, never stored as written. On the API a bad key returns 422. On a bulk write the row is reported as skipped by its row number in the set and the rest of the set is still written — a store with a forked record is worse than a store with a reported reject, because the fork is silent and permanent.

Layers

No source writes the record. Each source writes only its own layer.
Three properties follow from that, and each one is a capability rather than a detail:
  • Sources contribute different, partial field sets. The union across layers is richer than any single source, which is what makes this a master record rather than a mirror.
  • A write replaces that source’s own layer wholesale. A source that stops asserting a value has that assertion disappear, and the resolved view falls through to the next source. Retraction is expressible without a delete.
  • Every other layer is untouched. A new integration joins by writing a new layer; it changes no existing writer and no existing values.
Each layer carries its own written_at and written_by, whether or not the values moved. That is what makes “the platform report layer is older than last night’s sync” a question you can answer, and it is why a re-run that changes nothing still updates the timestamp.

The resolved view and precedence

resolved is one value per field, computed from the layers and stored on the record, so a reader that needs the current answer does a single read rather than a computation. Precedence is a store-wide default order with optional per-field overrides, because a single order cannot express “the platform report is authoritative for the campaign but should never touch the owning team” — and that is a real configuration, not a hypothetical.
For each field, take its precedence list — the per-field override if there is one, otherwise the default order. Walk it in order. The first layer that asserts the field wins. A key present in a layer is an assertion, including one whose value is null. A key absent is not an assertion.
That last clause is load-bearing. A hand edit setting owner_team to null is saying “I assert there is no owning team”, and it beats a lower source that names one. Saying nothing is different from saying nothing-is-there, and the store keeps them different. A field’s precedence list is also its writer allowlist. A source absent from a field’s list could never win it, so an assertion it made would be inert — the write is refused rather than stored somewhere it can never be read. One list answers both “who wins this field” and “who may say anything about it at all”. The shipped store’s defaults:
Read each override as the exclusion it is: owner_team resolves from a hand edit or your CRM, and neither the platform report nor an uploaded spreadsheet may write it at all. carrier resolves from a hand edit, a per-number lookup or an uploaded spreadsheet, and the reference data may not write it; exchange_carrier names the reference data alone, so nothing else may write that one. Two carrier facts, kept apart is why those are two fields rather than two ranks of one. The overrides above are abridged to the ones this page discusses. GET /data/stores/{store} returns the whole map, including the geographic and port fields that resolve from a single source each.

Changing precedence

Precedence lives at /data/stores/{store}/precedence — GET to read it, PUT to replace it, DELETE to revert to the store’s shipped defaults. All three require an admin-role caller (an X-API-Key is admin-equivalent), because this is the setting that decides which source wins every field for the whole account. Reordering sources changes which value resolves without rewriting any source’s data. Nothing is lost and nothing is migrated: the layers are already there, and the resolved view is recomputed from them. Every change bumps config_version — including a revert to the defaults — and every record records the version its resolved view was computed with. Recompute then runs in the background.
While a recompute is running, bulk queries legitimately mix precedences. Some records still carry a view computed under the old settings and some carry the new one. recompute.pending is the gate: it counts the records still behind, and zero is how you know the window has closed. Read it on GET /data/stores/{store} or on any precedence response, and gate a bulk read on it rather than on elapsed time.
Every record carries resolved_config_version, the precedence version that produced its view, so a record that has not been recomputed yet says so on its face rather than looking current. That is also what makes a resolved value explainable after the fact: you know which precedence produced it, and the layers it was computed from are still there.

History

Every store keeps an audit trail: one row per layer version, append-only, oldest first on GET /data/stores/{store}/records/{key}/history. A version is appended when a source’s values actually change, not on every write. A nightly sync that re-asserts identical values adds nothing at all — which is the only reason an audit trail over a store synced every night stays a readable size instead of growing by its whole record count every day.
A record with no history returns an empty page rather than a 404: a key that exists and has never changed is a real answer. History is cursor-paged and reports no total, because an audit trail grows without bound and counting it is the one thing this surface should not offer.

A number’s timeline

History answers “what did each source say, and when”. The timeline answers a different question: what happened to the number itself. Those are two logs with two shapes, and they live on two endpoints rather than one with a switch. Both share one contract:
  • Newest first, cursor-paged on next_cursor. Pass the cursor you were given back as ?cursor= for the next page.
  • limit defaults to 50 and is capped at 200.
  • Optional since and until ISO-8601 bounds narrow the window.
  • total is null. The log is append-only and grows without bound, so this surface does not count it. Narrow the window instead.
  • A record with no entries returns an empty page, not a 404 — a number that exists and has never moved is a real answer. A key that does not exist, or belongs to another account, returns the same 404 as everywhere else on this surface.
A reputation entry carries carrier, flagged, source, evidence_ref and at. The carrier key * is an observation that is not attributed to one carrier. source on either tab is one of census, import, operator, reference, reputation or system — what produced the entry, not which system it was read from.
The timeline and the history are not the same log, and neither replaces the other. History is per layer and per version: it answers “why is this value what it is”. The timeline is per number: it answers “why did this number stop dialing”. A number can move through its whole lifecycle without any source changing a single field, and a source can rewrite every field without the number moving at all.

What the platform measures about a number

Alongside resolved, every record carries four blocks the platform writes and no precedence reaches. They are not layers — no source writes them and no precedence setting changes them.
Null means nothing has been measured yet. It is not a zero and it is not a band. "health": null says the platform has not looked; it does not say the number is at the bottom of the scale. Likewise "reputation": {} means no carrier has reported anything, not that every carrier reports the number clean. Read and render the two states differently, or an account nobody has measured reports as an account in perfect shape.
census.callback_capable is false when the number answers on no inbound campaign — somebody who returns the call reaches nothing. The store detail reports the same thing for the whole store as callback_incapable_count.

When a number stops appearing in the inventory

An inventory check is an ownership census: every number the account holds is expected to appear in it, so a number’s absence is the only signal that it is no longer held. That makes absence dangerous to act on, because a check that returned a short answer looks exactly like an account that got smaller. Three guards stand in front of it, and none of them is configurable away: census.missing_since is therefore a warning, not a removal: it is the date the inventory first failed to mention the number. A number that comes back has it cleared, and nothing else about the number ever moved. A number that is finally retired moves to lifecycle.state: "disabled" with reason number_released, actor system:census, and an entry in its own history naming the check that did it. Its layer from the dialler sync is emptied at the same time, so a value you set by hand or by upload resolves again instead of being outranked forever by a claim nothing is making any more. Two knobs on the census flow widen the middle guard: how many checks in a row must miss a number, and the share that triggers the abandon. Both are definition variables, so editing one is visible as a customization of the flow rather than a silent change. Incomplete and abandoned checks never count toward “in a row”.

Reference-data lag

Geographic facts about a number — its exchange point, time zone and rate centre — come from reference data the platform publishes in generations, as do the two area-code fields described in the next section. Every record is stamped with the generation its facts came from, and a background sweep brings records that are behind up to the current one. GET /data/stores/{store} reports where you stand:
The generations named are the ones the count was taken against — read once, counted against exactly them. A count taken against one generation and reported beside another would be a number nobody could act on.
live_snapshot_ids: null is “never swept”, not “nothing is behind”. When no sweep has completed, count is how many records carry no generation stamp at all and last_sweep_at is null too. An empty-but-present list with count: 0 is the other answer: we swept, and everything is current. A reader that collapses the two reports an unmeasured store as a healthy one.A sweep that stopped at its page budget is deliberately not reported as last_sweep_at. It did not visit the whole store, so its finishing time would overstate the coverage behind the count beside it.
The sweep works through the store a page at a time and records where it stopped, so a run that is cut short — by its page budget or by anything else — continues from the same place next time rather than starting over. Stopping at the budget is a normal outcome, not a failure. When a new generation is published the sweep starts again from the beginning, because every record is behind it, including the ones an earlier run had already reached.

When the reference data cannot place a number

Not every number can be given a location, and a number that cannot is recorded as such rather than left out. A number left out would be invisible twice over: absent from the count of records still waiting, and silently missing from any feature that ranks numbers by where they are. Each swept record therefore carries geo_eligible, and when that is false, geo_ineligible_reason says which of two situations applies: The two are kept apart because they call for different things: the first is missing reference data, the second is reference data that is present and simply has no coordinates — which is the case for roughly one exchange in twenty-eight. tz is separate again, and a null one is normal rather than an error: roughly one exchange in five names no time zone, so a reader should fall back rather than treat the absence as a failed update.

Two carrier facts, kept apart

“Who carries this number” has two different answers, and the store keeps them in two separate pairs of fields rather than one pair, because they are two separate facts that legitimately disagree. The area code carrier is not the number’s carrier. A number keeps its digits when it is ported to another carrier; the block it was issued from does not move with it. So exchange_carrier goes on naming the carrier that block was allocated to long after the number has left — it is a fact about a block of ten thousand numbers, right about most of them and wrong about every ported one. Read it as what numbers like this one generally do, never as an answer about this one. carrier and line_type stay null until a per-number lookup has answered. They are never filled in from the area code to make a column look complete, and the precedence table is what enforces that rather than a convention somebody has to remember: reference is absent from both fields’ lists, so the reference data could not write them even by mistake. The reverse holds too — exchange_carrier and exchange_line_type name reference alone, so no lookup and no hand edit can overwrite the estimate. The two axes have disjoint writers and never contend. exchange_line_type reports a deliberately coarser vocabulary than line_type — landline, wireless, voip or unknown. An allocation code for a whole block cannot tell a mobile from a toll-free number, so it does not pretend to.

A lookup never erases what it did not find out

A lookup writes only the fields it got an answer for. A field it could not answer is left out of the write rather than written as null, and the precedence rule above is why that distinction matters: a present null is an assertion, so it would win the field and erase a value you set by hand or uploaded, while an absent key asserts nothing and lets the lower source keep it. One field is written whatever came back: looked_up_at, the moment the answer was obtained. A number that was looked up and yielded nothing still carries it, because “we asked and learned nothing” and “nobody has asked” are different states and the store keeps them different. A number the lookup could not accept at all gets no lookup values and no looked_up_at; it is counted as failed and retried rather than dropped quietly.

How much of a store has been looked up

GET /data/stores/{store} reports the store’s lookup provenance beside its reference-data lag:
looked_up and estimated_only never overlap: a number with a per-number answer is counted in the first and nowhere else, whatever its area-code estimate says. A number waits in owed rather than failing the import that introduced it, so a queue there is a delay and never lost data.
last_sweep_at: null means no sweep has completed. That is not the same answer as a sweep that found nothing outstanding. Three states are distinct and belong on screen distinctly: never swept — null, and the counts beside it are a first reading rather than a steady state; swept, nothing outstanding — a timestamp with owed and failed at zero; and swept, work outstanding — a timestamp with counts above zero. Collapsing the first into the second reports a store nobody has looked at as a store in perfect shape, which is the same mistake as reading "health": null as a zero.

Change events

A store emits its change event when a resolved value changes — not when a layer is written. That is deliberate and it is what makes push-back flows safe. The pattern the store is built for is: a resolved value changes, an event fires, a flow pushes the new value out to the platform it came from, and the next sync reads it back and writes its layer. Firing on layer writes would make that loop run forever. Firing on resolved change terminates it structurally, because the sync is re-asserting a value that already resolved, so nothing changed and no event is emitted. Two event types, and they answer different questions: Both names are on GET /data/stores/{store} as changed_event_type and summary_event_type — subscribe to them with an event subscription exactly like any other event. Change events are coalesced per write call — one event for the whole call, not one per record — and the payload is a reproducible reference rather than a snapshot:
A consumer re-derives the changed set by querying that window, so consumption that is late, retried or replayed never dangles on a value that has since moved on. keys is inlined only for small changes — past a couple of dozen records the event carries the count and the window and you query for the rest. The completion event carries that same reference plus the run’s totals: records_seen, resolved_changed, layer_only, unchanged and rejected. layer_only is worth knowing about — it counts records where this source’s values changed but the resolved view did not, because a higher-precedence source still wins those fields.

Writing into a store

Three doors, and they all go through the same engine — the same layer semantics, the same precedence, the same history, the same events.

A flow, over a record set

The general path. A record set carries the rows and a flow step writes them into one named layer with the getdialed__data__write_store_layer action:
field_mapping reads store field to your column, so a column header of yours can never become a store field. The key column is named separately by key_field and is the one value the store normalizes. A store field you leave out of the mapping is not written, and a column your rows do not carry contributes nothing rather than blanking the field. The records are read from the set as the step runs, so a set of any size is written without its contents travelling inside the flow. The step returns how many records it saw, how many resolved-changed, how many were layer-only or unchanged, and the row numbers of any it skipped.

Seeing what a write would replace, first

Writing a layer replaces whatever that layer previously said about each record in the set. Before a bulk import — especially one targeting manual, where what it replaces is somebody’s hand-made decisions — set mode to preview:
A preview writes nothing and emits nothing. It returns would_replace — how many of the records already hold information from this layer, and would therefore lose it — alongside unresolvable_keys, which counts the records whose key column held nothing usable. Read the two together: a file that matched nothing at all reports would_replace: 0 just as a file that genuinely replaces nothing does, and unresolvable_keys is what tells them apart. It almost always means the wrong column was named in key_field. Run the same step again with mode omitted (or set to apply) to carry the write out.
The write is set-only. It touches only the records it was given. A record missing from the set is left exactly as it was and is never treated as deleted — so “this number is gone” is not something a write can express by omission. That question is answered by a separate inventory check with its own guards; see when a number stops appearing in the inventory.

An uploaded spreadsheet

Upload the file as a record set and trigger the same kind of flow with its id. Any uploaded spreadsheet writes the csv_file layer; the filename is recorded in that layer’s provenance, so two uploads are told apart there rather than by layer name. The later upload replaces the earlier one’s assertions, which is the wholesale-replacement rule doing exactly what it says.

A hand edit

PATCH /data/stores/{store}/records/{key} writes the manual layer for one record. It needs an authenticated account — not an admin role, unlike precedence.
The body replaces your manual assertions wholesale — a field you omit stops being asserted manually and resolution falls through to the next source, and {"layer_data": {}} withdraws every manual assertion on the record. A field your precedence does not allow manual to write is refused with 422. An edit cannot create a record: a key with no record returns 404.

How long a correction holds

A correction to a label — a friendly name, an owning team — should stay until you change it. A correction to a fact the platform keeps up to date for you — a carrier, a line type — usually should not: once an automated source learns the real value, your stand-in has done its job and holding it would hide the truth. For carrier and line_type that automated source is a per-number lookup; the two area-code fields beside them are not correctable by hand at all, because only the reference data may write them and an edit naming one is refused with 422. That is one setting per field, on the same body:
  • true — the correction is replaced the next time an automated source writes that field.
  • false — the correction is pinned until you change it.
  • Omit a field, or the whole object, and the store’s own default applies: facts yield, labels are pinned.
Every name in yield_to_newer must also appear in layer_data; a setting for a value you are not asserting describes nothing and is refused with 422. When a lower source does replace a yielding correction, the replacement is recorded, not silent: a history version is appended for your manual layer naming the fields that were retired and the layer that retired them, so “who took my correction away” is answerable afterwards. Read the settings currently in force back from GET /data/stores/{store}/records/{key}?include=layers, on the manual layer’s yield_to_newer.

The two shipped writers

Company phone numbers ship with two writer flows, seeded into your account as ordinary definitions you can inspect, edit and trigger like any other. Their field sets do not overlap, and that is the point. Two writers describing the same numbers with disjoint, equally valid facts is the case a single-sync model cannot represent: neither loses, the resolved record is richer than either source, and include=layers says which one supplied any value.
The census sync reads an ownership census — every number the account holds appears in it — which is why it carries an explicit, deliberately wide date range as definition variables. That report will not default a time range, so the range is part of the flow rather than an assumption. Widen it by editing the variable if your history goes back further.
The census sync is seeded without a schedule. Trigger it manually while you are getting to know it, and commit a schedule once you have picked a window — off-hours, and not overlapping a heavy load you already run.

Reading a store back

The records list filters on any field the store declares — repeat filter=field=value for more than one — and its total always describes the same filtered population as the page beside it. Paging is cursor-based, because a store is browsed while syncs write to it and an offset page silently repeats or drops rows under concurrent writes.
A record that does not exist and a record belonging to another account return the same 404, never a 403. On this store that is not a formality: the record key is a phone number, so a 403 would answer “does this account hold this number”, which is itself the disclosure.

Next steps

Bulk ingestion

Upload the rows a store write works from, as a record set.

Event subscriptions

Trigger a flow from a store’s change or completion event.

Catalog

The layer-write action’s full parameter and output reference.

Flow definitions

Edit a seeded writer flow, or author your own.