Touchstone B2B Data API — Zach Squeer Integration Guide#
Issued: 2026-05-04
Last updated: 2026-05-06 — added per-voter disposition on /v1/door-knock-logs (see §"What's new" below)
Recipient: Zach Squeer
Issuer contact: gerrit@touchstone.vote, john@touchstone.vote
What's new (2026-05-06)#
/v1/door-knock-logs rows now carry a new voters field — a JSON array of {voter_id, contact_result, survey_completed} objects, one element per voter at the knock (including secondaries). The existing scalar voter_id / contact_result / survey_completed continue to project the primary voter, so this is purely additive — your existing code keeps working unchanged. If you want per-voter outcomes for multi-voter households, read row.voters[].
This closes the previous "multi-voter knocks lose secondary voters" caveat. See updated guidance in §2 (caveat 8), §7 (/v1/door-knock-logs), and §11.
Verified live on production 2026-05-06 against your RFS - Zach campaign.#
2. Live-data caveats specific to your campaigns#
These were verified against production on 2026-05-04. Tell us if any of them block your integration.
territories.stateis empty for all 3 RFS Testing territories. The schema column exists; data was not populated on upload. Until you re-upload RFS territory data withstateset, the state differentiator returned by/v1/territorieswill be"".- RF - Montana has zero data on prod. All five data endpoints for the Montana key return
{"data":[],"has_more":false}./v1/campaignsdoes return the Montana campaign row. The key starts working the moment data lands. - Voter ID columns are sparse on RFS - Zach. All 1000 voters have
van_idpopulated;state_voter_id,national_van_id,county_voter_id, andvendor_voter_idare NULL on every row. Usevan_idas your join key against your VAN records. voter_identity_idis the cross-reimport-stable join key, but is only available on/v1/voters. Knock and survey rows carryvoter_idonly —voter_identity_idis intentionally not projected on/v1/door-knock-logsor/v1/survey-resultsfor cursor-freshness reasons (see caveat 6 below). The supported pattern: maintain a voters cache from/v1/votersand resolve any knock or survey row'svoter_idagainst that cache.gps_timestampis epoch milliseconds as abigint. Sample value1776707626735. Convert withnew Date(gps_timestamp)(JS) orto_timestamp(gps_timestamp / 1000.0)(SQL).- Three timestamps on knock and survey rows mean different things:
completed_at— when the canvassing event happened in the field. Use for "when was this knock/survey done."created_at— when the row landed in our database.updated_at— the polling cursor key. Bumped by triggers (rolled-back flips, EID/name corrections). Use this only as theupdated_aftercursor; do not use it as a stable event time.households.geocoding_statusis not the geocode-quality signal you want. All 662 RFS - Zach rows havelocation_geojsonpopulated andgeocoding_status = 'complete'(the success terminal state). Note the enum is('pending', 'complete', 'error')— there is no'success'value, so a literalgeocoding_status = 'success'filter will discard every row (and raiseinvalid input value for enumif you ever query the column directly). Uselocation_geojson IS NOT NULL(plus a non-empty coordinates check) as the geocode-quality filter, not thegeocoding_statusenum.- Multi-voter knocks expose all voters via the
votersarray (as of 2026-05-06). Each/v1/door-knock-logsrow carries avoters: [{voter_id, contact_result, survey_completed}, …]array enumerating every voter at the knock — including roommates with split outcomes. Sorted ascending byvoter_id. Never null (empty array on the rare zero-junction case). The scalarvoter_id/contact_result/survey_completedcontinue to refer to the primary voter and are unchanged. To resolve names / addresses for any element, joinvoters[].voter_idagainst your/v1/voterscache. - Survey responses are keyed by question UUID with no
/v1/surveysresolver. Shape:The reserved{ "<question-uuid>": { "answer": "..." }, "<question-uuid>": { "answer": "..." }, "_notes": "Free-text canvasser notes go here." }"_notes"key holds free-text. Clients must look up question UUIDs against their own catalog. - Polling re-emits a territory on every knock to it. The
update_territory_last_knocked_attrigger bumps territoryupdated_aton every knock. This is correct for theupdated_aftercontract; just keep your upsert idempotent onid. - Canvasser names are user-controlled and must be escaped on BI ingest. The server rejects strings that begin with
=,+,-,@, tab, CR, or LF (CSV/spreadsheet formula-injection prefixes) and caps both fields at 80 chars — but downstream BI / spreadsheet tooling should still apply normal CSV-escape and HTML-escape rules. Treat them as untrusted user content. - Old key
tsk_live_1vqrKLQ…is being phased out. It's still active and expires naturally on 2026-05-28; please cut over to the new keys above. The old key will be revoked once we see zero calls on it for 48 hours.
3. Authentication#
Send your key as a Bearer token on every request:
GET /functions/v1/b2b-api/v1/voters?limit=100 HTTP/1.1
Host: awntbgiwhcfpbfnwmmfl.supabase.co
Authorization: Bearer tsk_live_xWT8yLA3FQrkEaYn1tKccavAyfO0FadIFnxMjmcmxI0
A bearer token is rejected with:
401 invalid_credentials— missing, malformed, or unknown token (or token doesn't start withtsk_live_)401 key_revoked—is_active = falseon the key401 key_expired— key past itsexpires_at
4. Base URL#
https://awntbgiwhcfpbfnwmmfl.supabase.co/functions/v1/b2b-api/v1/<resource>
Example: https://awntbgiwhcfpbfnwmmfl.supabase.co/functions/v1/b2b-api/v1/voters?limit=100
All endpoints are GET only. Wire format is JSON.
5. Pagination & query parameters#
All endpoints accept the same four parameters:
| Param | Type | Notes |
|---|---|---|
campaign_id |
UUID | Optional for your campaign-scoped keys — server filters automatically. (Required only on program-scoped keys, except on /v1/campaigns.) |
updated_after |
ISO 8601 timestamp | Inclusive lower bound on updated_at. URL-encode the + in timezone offsets as %2B (e.g. 2026-05-04T22:00:00%2B00:00). |
cursor |
base64url string | Returned in next_cursor from the previous page. Treat as opaque. Continues iteration in stable (updated_at, id) order. |
limit |
integer 1..1000 | Default 100. Server-side cap is 1000 regardless of what you send. |
updated_after and cursor may be combined: the cursor is the precise resume point inside an updated_after window.
Response envelope#
Every successful response is the same JSON shape:
{
"object": "list",
"data": [ /* array of rows for this resource */ ],
"next_cursor": "eyJ1cGRhdGVkX2F0IjoiMjAyNi0wNS0wNFQ...",
"has_more": true
}
When has_more is false, next_cursor is null and you have consumed the full window. When has_more is true, request the next page with cursor=<next_cursor>.
Order is always (updated_at ASC, id ASC). There is no offset pagination. There is no count: 'exact'.
6. Error responses#
| Status | error |
When |
|---|---|---|
| 400 | invalid_request |
Malformed campaign_id, updated_after, limit, or cursor. Body includes a detail field. |
| 400 | campaign_id_required_for_program_scoped_keys |
(Doesn't apply to your keys — they're campaign-scoped.) |
| 401 | invalid_credentials |
Missing, malformed, or unknown bearer token. |
| 401 | key_revoked |
Key has is_active = false. |
| 401 | key_expired |
Key past its expires_at. |
| 404 | not_found |
Unknown resource path or non-GET/HEAD method. |
| 429 | rate_limited |
Token bucket empty. Refill is ~2 tokens/sec — wait ~½ s for a single token, ~60 s for a full bucket. |
| 500 | internal_error |
Unhandled server error. The audit log retains the request for incident triage — flag it to us with the timestamp. |
7. Endpoints#
The migration is the source of truth for column lists; if anything below disagrees with what you actually receive, trust the API response and tell us.
GET /v1/voters#
Per-voter records. ~70 columns.
id, campaign_id, household_id, first_name, last_name, middle_name, name_suffix, party_affiliation, precinct, city_council_district, age, birthdate, gender, county, ethnicity, registration_date, van_id, national_van_id, state_voter_id, county_voter_id, phone_landline, phone_cell, do_not_call, contact_status, no_contact_reason, last_contact_date, last_canvasser_id, last_canvasser_eid, last_canvasser_first_name, last_canvasser_last_name, survey_count, survey_completed, partisan_score, turnout_score, universe_tag, vote_status, vote_method_preference, vote_history_pp2016, vote_history_m2018, vote_history_pp2020, vote_history_m2022, vote_history_pp2024, street_no, street_prefix, street_name, street_type, street_suffix, apt_type, apt_no, address, city, state, zip_code, zip4, v_address_id, polling_location, precinct_address, cluster_id, region_gid, region_name, is_apartment, created_by, created_at, updated_at, vendor_voter_id, language, vote_frequency, us_congressional_district, state_senate_district, state_house_district, voter_identity_id, tags
voter_identity_idis the cross-reimport-stable join key — use it as your warehouse primary key.last_canvasser_*(eid + first_name + last_name) is the most-recent canvasser to touch this voter, not all of them. Per-knock attribution lives on/v1/door-knock-logs.- No soft-delete column on voters in v1 (no
rolled_back).
GET /v1/households#
Per-household records.
id, campaign_id, v_address_id, address, normalized_address, address_formatted, city, state, zip_code, county, unit, is_apartment, do_not_call, location_geojson, geohash, geocoding_status, geocoding_error, geocoding_location_type, geocoding_formatted_address, geocoding_partial_match, canvassing_order, contact_status, no_contact_reason, voter_count, party_summary, contact_attempts, successful_contacts, last_contact_date, last_contact_attempt, last_successful_contact, last_canvasser_id, last_canvasser_eid, last_canvasser_first_name, last_canvasser_last_name, last_modified_by, literature_delivered, literature_delivery_date, literature_type, rolled_back, rolled_back_at, rolled_back_by, rollback_count, version, locked_until, cluster_id, region_gid, region_name, precinct, city_council_district, polling_location, precinct_address, created_at, updated_at, vendor_household_id
location_geojsonis GeoJSON Point;nullwhen location is unknown.- For RFS - Zach, prefer
location_geojson IS NOT NULLovergeocoding_status='success'(see caveat 7 above). rolled_back = trueindicates a soft-deleted household; treat as logical deletion in your warehouse.
GET /v1/door-knock-logs#
Per-knock event log.
id, campaign_id, program_id, household_id, territory_id, canvasser_id, canvasser_eid, canvasser_first_name, canvasser_last_name, voter_id, contact_result, contact_method, survey_completed, survey_result_id, completed_at, location_geojson, gps_accuracy, gps_timestamp, rolled_back, rolled_back_at, rolled_back_by, created_at, notes, literature_left, idempotency_token, voters, updated_at
- The scalar
voter_id/contact_result/survey_completedreflect the primary voter on the knock (unchanged contract). voters(jsonb, added 2026-05-06) is a JSON array of{voter_id, contact_result, survey_completed}objects — one element per voter at the knock, sorted ascending byvoter_id, never null. The primary voter is always one of the elements. For multi-voter households this is the full picture; for single-voter knocks it's a 1-element array containing the same voter as the scalar columns.- To resolve voter identity for any element of
voters[](or for the scalarvoter_id): joinvoter_idagainst your/v1/voterscache to getvoter_identity_id+ name + address.voter_identity_idis intentionally not duplicated intovoters[]— keep using the cache. - Example multi-voter row:
{ "id": "8b1e83da-…", "voter_id": "c76d9e2d-…", // primary "contact_result": "Contacted", // primary "survey_completed": true, // primary "voters": [ { "voter_id": "c76d9e2d-…", "contact_result": "Contacted", "survey_completed": true }, { "voter_id": "e6638d1c-…", "contact_result": "Refused", "survey_completed": false } ], "updated_at": "2026-04-28T19:45:27.582599+00:00" } location_geojsonis the GPS coordinate of the knock event itself (not the household).gps_accuracyis in meters;gps_timestampis epoch ms.rolled_back = trueindicates a soft-deleted knock; treat as logical deletion.survey_result_idlinks to/v1/survey-results.idwhen a survey was completed at this knock.- For multi-voter knocks where multiple voters were surveyed, expect one
/v1/survey-resultsrow per surveyed voter at this knock — join viadoor_knock_log_idandvoter_id. Voters invoters[]withsurvey_completed: falsewill have no correspondingsurvey_resultsrow; that's expected.
GET /v1/survey-results#
Per-survey response row.
id, campaign_id, door_knock_log_id, survey_id, canvasser_id, canvasser_eid, canvasser_first_name, canvasser_last_name, household_id, voter_id, territory_id, responses, conversation_path, contact_method, survey_type, completed_at, completed_branches, idempotency_token, upload_batch_id, session_id, location_geojson, rolled_back, rolled_back_at, rolled_back_by, created_at, updated_at
responsesis ajsonbobject keyed by question UUID with the reserved_noteskey for free-text. (See caveat 9.)door_knock_log_idis the join key back to/v1/door-knock-logs.voter_identity_idis not on this endpoint; resolve viavoter_id → /v1/voters.
GET /v1/territories#
Territory metadata + denormalized counters.
id, campaign_id, program_id, territory_group_id, name, territory_code, status, status_updated_at, state, county, city, zip_code, is_active, is_assigned, is_completed, admin_completed, completed_date, completion_percentage, total_households, total_voters, doors_knocked, voters_contacted, contact_attempts, literature_delivered, last_knocked_at, created_at, updated_at
- Counters are denormalized eventual-consistency aggregates.
total_households,total_voters,doors_knocked,voters_contacted,contact_attempts,literature_delivered,completion_percentagereflect trigger-maintained rollups. For exact counts, aggregate from/v1/door-knock-logsand/v1/survey-results.surveys_completedis intentionally not surfaced — derive it client-side by countingvoters[].survey_completed = trueacross all/v1/door-knock-logsrows for the territory; the scalarsurvey_completedreflects the primary voter only and undercounts at multi-voter knocks. - No PostGIS polygon (
boundary/centroid) in v1. - No canvasser-PII columns (
assigned_to_canvasser_id,completed_by,last_modified_by) — attribution flows through the per-knock canvasser fields. - No tombstone signal — periodically full-resync if exact deletion tracking matters.
GET /v1/campaigns#
Tiny enumeration endpoint.
id, program_id, name, status, created_at, updated_at
- For your campaign-scoped keys, this returns exactly the one campaign each key is bound to.
- No PII, no rollups.
8. Polling loop (TypeScript pseudocode)#
const BASE = 'https://awntbgiwhcfpbfnwmmfl.supabase.co/functions/v1/b2b-api';
const KEY = process.env.TOUCHSTONE_KEY!; // tsk_live_...
async function pollResource(resource: string) {
let cursor = await loadCursor(resource); // null on first run
let updatedAfter = await loadUpdatedAfter(resource); // null on first run
while (true) {
const params = new URLSearchParams({ limit: '1000' });
if (cursor) params.set('cursor', cursor);
if (updatedAfter) params.set('updated_after', updatedAfter); // URLSearchParams encodes + correctly
const res = await fetch(`${BASE}/v1/${resource}?${params}`, {
headers: { Authorization: `Bearer ${KEY}` },
});
if (res.status === 429) {
await sleep(30_000);
continue;
} // rate-limited; back off
if (!res.ok) throw new Error(`status=${res.status} body=${await res.text()}`);
const page = await res.json();
for (const row of page.data) {
await db.upsert(resource, row, { onConflict: 'id' }); // idempotent
}
if (!page.has_more) {
// Window done. Persist the watermark for next incremental run.
const max = page.data.reduce(
(m: string | null, r: any) => (!m || r.updated_at > m ? r.updated_at : m),
updatedAfter,
);
await saveUpdatedAfter(resource, max);
await saveCursor(resource, null);
break;
}
cursor = page.next_cursor;
await saveCursor(resource, cursor);
}
}
Notes:
- Persist the cursor after every page. On crash, resume from the saved cursor — no duplicate ingest beyond the in-flight page.
- Upsert by
idon the client side. A row may legitimately re-appear in a later page if it was updated mid-run; that's expected and idempotent upsert handles it. - Run one resource at a time per loop; parallelize across resources only after a successful single-threaded run.
- For a clean backfill, set
updated_afterto a sentinel like1970-01-01T00:00:00Z, then save the largestupdated_atyou saw and switch to incremental polling thereafter.
9. Backfill guidance for larger pulls#
- Use
limit=1000(the maximum) so each page is one request. - Default rate limit (
120 req/min) gives ~120,000 rows/min in the best case. For a multi-million-row backfill from your end, ask us to raise to 300/min or 600/min and we'll re-run our scale gate against your data. (Your campaigns today don't need this.) - The bottleneck on a big backfill is usually your network and upsert throughput, not our API. Tune from there before asking for more headroom.
10. Hard delete & tombstone behavior#
door_knock_logsandsurvey_resultsandhouseholdscarryrolled_back. When something is rolled back, the row is updated and re-emitted withrolled_back = true. Treat that as a logical deletion in your warehouse.votersandterritoriesandcampaignsdo not have a soft-delete flag in v1.- v1 does not surface hard deletions. If a row is deleted outright, it does not re-appear with
updated_after. If exact deletion tracking matters to you, periodically full-resync that resource (or just that table's row count) and reconcile.
11. What's NOT in v1#
We turned these down on purpose; ask if you need them:
- No PostgREST passthrough (
?select=,?or=, arbitrary filters). - No async file exports / signed-URL bulk dumps.
- No
count: 'exact'. - No offset pagination — cursors only.
- No client-selected sort order — always
(updated_at ASC, id ASC). - No
voter_identity_idon knock or survey rows (resolve via/v1/voters). - No
surveys_completedon/v1/territories(derive fromvoters[].survey_completed = trueacross/v1/door-knock-logs). - No
/v1/surveysresolver (question-UUID lookups are out-of-band today). - No PostGIS polygon (
boundary/centroid) on/v1/territories. - No HMAC-signed cursors (cursors are opaque but unsigned by design — scope is enforced server-side, not in the cursor).
If any of these is a blocker, tell us — most are deferred, not refused.
12. Quick-start sanity check#
Drop these into a shell to confirm your key works end-to-end:
KEY="your_api_key"
BASE="https://awntbgiwhcfpbfnwmmfl.supabase.co/functions/v1/b2b-api"
for r in voters households door-knock-logs survey-results territories campaigns; do
echo "=== /v1/$r ==="
curl -sS -H "Authorization: Bearer $KEY" "$BASE/v1/$r?limit=1" \
| jq '{has_more, count: (.data | length), first_id: .data[0].id}'
done
Expected for the RFS - Zach key: each endpoint returns 200 with count: 1 (or more) and has_more either true or false.
Expected for the Montana key: 200 with count: 0 on the five data resources (no data yet); count: 1 on /v1/campaigns.
Verifying the new voters array#
curl -sS -H "Authorization: Bearer $KEY" \
"$BASE/v1/door-knock-logs?limit=1000" \
| jq '{
total_knocks: (.data | length),
multi_voter_knocks: ([.data[] | select(.voters | length >= 2)] | length),
max_voters_per_knock: ([.data[] | (.voters | length)] | max),
sample_multi_voter: ([.data[] | select(.voters | length >= 2)][0] | {id, voters})
}'
For your RFS - Zach campaign on production today, expect total_knocks: 11, multi_voter_knocks: 5, max_voters_per_knock: 4, and a sample row showing a voters array with 2-4 elements and split outcomes.
13. Support & escalation#
- API issues, schema questions, requested fields: email gerrit@touchstone.vote and john@touchstone.vote with the request timestamp and key prefix (visible to us in audit logs; never include the full secret).
- Security incident (key suspected compromised): email gerrit@touchstone.vote and john@touchstone.vote with subject line
B2B API key compromise — <prefix>. We'll revoke immediately and reissue. - Quota / rate limit raise: ask once you know your steady-state polling rate; default 120/min is right for testing.
That's the whole contract. If anything in this document doesn't match what you observe in the API, the API response is authoritative — please flag the discrepancy and we'll either fix the doc or fix the API.