Skip to content

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.

  1. territories.state is empty for all 3 RFS Testing territories. The schema column exists; data was not populated on upload. Until you re-upload RFS territory data with state set, the state differentiator returned by /v1/territories will be "".
  2. RF - Montana has zero data on prod. All five data endpoints for the Montana key return {"data":[],"has_more":false}. /v1/campaigns does return the Montana campaign row. The key starts working the moment data lands.
  3. Voter ID columns are sparse on RFS - Zach. All 1000 voters have van_id populated; state_voter_id, national_van_id, county_voter_id, and vendor_voter_id are NULL on every row. Use van_id as your join key against your VAN records.
  4. voter_identity_id is the cross-reimport-stable join key, but is only available on /v1/voters. Knock and survey rows carry voter_id only — voter_identity_id is intentionally not projected on /v1/door-knock-logs or /v1/survey-results for cursor-freshness reasons (see caveat 6 below). The supported pattern: maintain a voters cache from /v1/voters and resolve any knock or survey row's voter_id against that cache.
  5. gps_timestamp is epoch milliseconds as a bigint. Sample value 1776707626735. Convert with new Date(gps_timestamp) (JS) or to_timestamp(gps_timestamp / 1000.0) (SQL).
  6. Three timestamps on knock and survey rows mean different things:
  7. completed_at — when the canvassing event happened in the field. Use for "when was this knock/survey done."
  8. created_at — when the row landed in our database.
  9. updated_at — the polling cursor key. Bumped by triggers (rolled-back flips, EID/name corrections). Use this only as the updated_after cursor; do not use it as a stable event time.
  10. households.geocoding_status is not the geocode-quality signal you want. All 662 RFS - Zach rows have location_geojson populated and geocoding_status = 'complete' (the success terminal state). Note the enum is ('pending', 'complete', 'error') — there is no 'success' value, so a literal geocoding_status = 'success' filter will discard every row (and raise invalid input value for enum if you ever query the column directly). Use location_geojson IS NOT NULL (plus a non-empty coordinates check) as the geocode-quality filter, not the geocoding_status enum.
  11. Multi-voter knocks expose all voters via the voters array (as of 2026-05-06). Each /v1/door-knock-logs row carries a voters: [{voter_id, contact_result, survey_completed}, …] array enumerating every voter at the knock — including roommates with split outcomes. Sorted ascending by voter_id. Never null (empty array on the rare zero-junction case). The scalar voter_id / contact_result / survey_completed continue to refer to the primary voter and are unchanged. To resolve names / addresses for any element, join voters[].voter_id against your /v1/voters cache.
  12. Survey responses are keyed by question UUID with no /v1/surveys resolver. Shape:
    {
      "<question-uuid>": { "answer": "..." },
      "<question-uuid>": { "answer": "..." },
      "_notes": "Free-text canvasser notes go here."
    }
    
    The reserved "_notes" key holds free-text. Clients must look up question UUIDs against their own catalog.
  13. Polling re-emits a territory on every knock to it. The update_territory_last_knocked_at trigger bumps territory updated_at on every knock. This is correct for the updated_after contract; just keep your upsert idempotent on id.
  14. 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.
  15. 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 with tsk_live_)
  • 401 key_revokedis_active = false on the key
  • 401 key_expired — key past its expires_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_id is 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_geojson is GeoJSON Point; null when location is unknown.
  • For RFS - Zach, prefer location_geojson IS NOT NULL over geocoding_status='success' (see caveat 7 above).
  • rolled_back = true indicates 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_completed reflect 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 by voter_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 scalar voter_id): join voter_id against your /v1/voters cache to get voter_identity_id + name + address. voter_identity_id is intentionally not duplicated into voters[] — 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_geojson is the GPS coordinate of the knock event itself (not the household).
  • gps_accuracy is in meters; gps_timestamp is epoch ms.
  • rolled_back = true indicates a soft-deleted knock; treat as logical deletion.
  • survey_result_id links to /v1/survey-results.id when a survey was completed at this knock.
  • For multi-voter knocks where multiple voters were surveyed, expect one /v1/survey-results row per surveyed voter at this knock — join via door_knock_log_id and voter_id. Voters in voters[] with survey_completed: false will have no corresponding survey_results row; 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

  • responses is a jsonb object keyed by question UUID with the reserved _notes key for free-text. (See caveat 9.)
  • door_knock_log_id is the join key back to /v1/door-knock-logs.
  • voter_identity_id is not on this endpoint; resolve via voter_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_percentage reflect trigger-maintained rollups. For exact counts, aggregate from /v1/door-knock-logs and /v1/survey-results. surveys_completed is intentionally not surfaced — derive it client-side by counting voters[].survey_completed = true across all /v1/door-knock-logs rows for the territory; the scalar survey_completed reflects 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 id on 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_after to a sentinel like 1970-01-01T00:00:00Z, then save the largest updated_at you 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_logs and survey_results and households carry rolled_back. When something is rolled back, the row is updated and re-emitted with rolled_back = true. Treat that as a logical deletion in your warehouse.
  • voters and territories and campaigns do 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_id on knock or survey rows (resolve via /v1/voters).
  • No surveys_completed on /v1/territories (derive from voters[].survey_completed = true across /v1/door-knock-logs).
  • No /v1/surveys resolver (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.