Integration

How to send Streta a list and receive the rows. Generated from the code that runs; schema 2026-10.

Send a list

We enrich your list and never build one. What you send is used for your job and nothing else, and it isn't sold or shared. We find each business's own published line and ask it two or three plain questions. We never ring the person on your row.

The columns

A CSV with a header row. It needs a company_name column and, for each business, a city or domain (a website) so we can find its own line; a phone is optional and is kept as the contact's number, never rung. A state (two letters) on each row decides which state's rules apply to the call and the recording; a row with no state is closed as such rather than guessed.

Of everything else on your row, only ref (and the other keys of rows merged into it) and domain are returned; the rest is used for your job and kept for you. A phone on a row with a city or website is the contact's number, kept as storage; we ring the listing's line and the file says your_number_differed: yes.

Your own key for each row comes back on the row as ref: send it as row_id or ref, or as any column that starts ref_. If a file sends several, row_id wins, then ref, then the first ref_ column.

Headers we read as ours

Headers are read in any case and spelling ("Company Name" is company_name). These are read as the column on the left unless the file already has that column:

We readYour header may be
company_nameaccount_name, business, business_name, company, name
phonemain_phone, mobile, phone_number, tel, telephone
domainsite, url, web, website, website_url
statest, state_code, state_province

The reader takes a comma, semicolon, tab or pipe as the separator, UTF-8 or Windows-1252, and a short row (its last cells blank). A file with the wrong columns is refused with the headers we found, and the response lists the names we read yours as (mapped_columns). The most one upload carries is 20 MB.

Rows we don't ring

A mobile line, a business in a state closed to an artificial voice, a row with no state, and a business whose own line we can't find aren't rung. Each comes back as a row, with the reason in words and as a value (not_called_reason), so every business you send that we could name comes back as a row, and the ones we didn't call are yours to call. A row with nothing to name (a duplicate of another row, no company name, no city or website, a number on our stop list) is on the list of rows we could not take, below.

Look before it dials

Add dry_run=true to the same request. It resolves the list, counts what would happen (found, not found with the reason, mobile, closed state, no state, how many numbers we would ring from) and writes nothing and rings no one. Read the counts with us before the first job.

curl -X POST "https://streta.ai/jobs?mission_id=$MISSION_ID&dry_run=true" \
  -H "X-API-Key: $STRETA_KEY" \
  -F "file=@list.csv"
import requests

r = requests.post(
    "https://streta.ai/jobs",
    params={"mission_id": MISSION_ID, "dry_run": "true"},   # drop dry_run to create the job
    headers={"X-API-Key": STRETA_KEY},
    files={"file": open("list.csv", "rb")},
)
print(r.status_code, r.json())

Send rows one at a time

A CRM that posts each new lead, a Clay column or a script can send rows into a mission that already exists, without a file. The mission is in the path; the rows are one object, a list of objects, or {"rows": [...]}, with the same column names a file takes (the header aliases above are read here too). Your own id for each row goes in ref.

curl -X POST "https://streta.ai/missions/$MISSION_ID/rows" \
  -H "X-API-Key: $STRETA_CLIENT_KEY" -H "Idempotency-Key: lead-2041" -H "Content-Type: application/json" \
  -d '{"company_name": "Heights Tire", "city": "Houston", "state": "TX", "ref": "crm-2041"}'

The answer is 202 and one entry per row, at once: {ref, streta_id, status}. streta_id is the id the row will carry in your file and in the webhook's sortie.id; ref is yours. The result of each row reaches your webhook per row, as it closes, and the file has it by wave. (A row that is the same business as another row you sent is merged into the first when the list is planned; the webhook's merged_refs says so.)

statusMeaning
queuedTaken. It is being prepared and booked; its result comes by your webhook.
already_acceptedYou sent this ref (or this Idempotency-Key) before; this is the id it has. Nothing was booked again.
refusedNot taken, with a reason (for example no_company_name). It has no streta_id.

Idempotency-Key is one per request (up to 200 characters): the same key again is answered with the rows it already made and books nothing. A ref this mission already has is answered already_accepted with the id it has, so a retry with a new key does not book a business twice. Send a ref on every row.

Rows go to the mission's job for the UTC day, made on the first row of the day. It is an ordinary job: the same pace, the same file, and job.finished when the day is over and its rows are closed. A client's first job is created held for a day so its counts are read before anybody is rung; the answer says held_until. The checks the file meets apply here (a number to ring from, the approved lines, the carrier). To look before you send, send a file with dry_run=true.

Your key

An account key is Streta's to give an agency; a client of its own has a key of its own. A person at your file makes it: /file, Settings, "A key for your tools" (Make a key). It is shown once and only its last four characters are kept; make one for each tool and revoke one without touching the rest. Send it in an X-API-Key header.

The key is one client's. It reads that client's rows and posts rows into that client's missions, and every other route answers 403:

RouteWhat it is for
GET /jobsYour jobs, newest first.
GET /jobs/{job_id}One job: its counts, its planning, the rows we could not take.
GET /jobs/{job_id}/not_acceptedThe rows we could not make into a call, each with your ref and why.
GET /jobs/{job_id}/progressWhere every row stands: queued, calling, retry_scheduled or finished, with your ref and the result.
GET /jobs/{job_id}/resultsThe job's file as a CSV; closed_since for what is new; headers=keys for the machine header.
GET /jobs/{job_id}/sortiesThe same rows, optionally one status.
GET /missions/{mission_id}/resultsA mission's file across its jobs, by day or by closed_since.
POST /missions/{mission_id}/rowsSend rows into one of your missions (above).
GET /stopsEvery business that asked us to stop, with the day and their words.

To pull what is new, ask for closed_since with the instant of your last pull; the response header X-Streta-Next-Cursor is the instant to ask from next time, and a pull that finds nothing new returns the one it was asked from.

What comes back

Every business we complete comes back as one of three results, in result_key: matches (the answer you were looking for), no_match (a definitive answer that isn't), or no_answer (no definitive answer, with the reason). A business that asked us to stop comes back as exactly that; honor it in your own outreach. A call that failed on our side is retried and is never shown to you or charged.

Each column is one of two kinds. Observable is what happened or is on the record: whether a person answered, the answer, the quote. Inferred is our reading, kept in separate columns whose keys end _inferred, and is never stored as a fact. Weight the two as you like.

Rows we could not take, and rows we could not complete

Every row you send is one of: a row of the file (a result, or a business we named and did not call, with the reason), a row we could not take, or a row we could not complete. The first three counts of job.finished are the file's, and the other two make rows sent reconcile with rows back: counts carries matches, no_match, no_answer, not_accepted, not_completed.

Could not take are rows with nothing to make into a call. They are not results, not a count on your page and not billed. They are a list, GET /jobs/{id}/not_accepted (JSON, or format=csv), and on /file a quiet download on the job page, each with your ref, the company, one reason from this closed set and the reason in words:

reasonIn words
no_listingGoogle has no listing for this business, so we had no line of its own to call
listing_not_confirmedwe found a listing but could not confirm it is this business (its name and place did not agree with your row)
permanently_closedGoogle lists this business as permanently closed
lookup_failedour lookup for this row did not complete; it says nothing about the business
no_phonethere was no phone number to call: your row had none and the business's listing has none we can call
duplicate_ofanother row of yours is the same business and is the one we worked
on_our_stop_listthis number is on our stop list, so we did not call it
no_company_namethe row has no company name
no_city_or_domainthe row has a phone number but no city or website to find the business's own line by
on_your_do_not_contact_listthis business is on the do-not-contact list you sent with this list, so we did not call it; we keep that list for this job only and use it for no one else
not_worked_from_a_listwe could not work this number from your list, so it was not called
answered_beforewe already asked this business for this mission and have its answer, which is on your earlier file under the id given; not asked again so soon (send reask=true to ask again)

A business we could name and did not call (no listing, a closed one, no phone, on our stop list) is also a row of the file, no_answer, "Not called by us: …". Could not complete are rows our own call failed on: never shown as a result, never charged, counted as not_completed and said on your page as "N rows we could not complete; not charged".

Where a row stands

GET /jobs/{id}/progress lists every row with your ref and one of these statuses, and result once finished:

statusMeaning
queuedNothing placed yet. A row you posted and we have not planned yet is here, under the id it was answered with.
callingA call is on the line now.
retry_scheduledWe tried and the row is still open: another try is booked, or we wait on a callback or a reply.
finishedClosed. result is matches, no_match or no_answer, or null for a row we could not complete.

A list over 2,000 rows to look up is answered 409 with its estimate until the request carries confirm_spend=true. A second file, suppression (a CSV with a phone and/or domain column), is your own do-not-contact list: a row on it is not called, comes back no_answer with that reason, and is read for that job only. GET /stops is every business that asked us to stop, with the day and their words, for your sequencer. POST /rows/{streta_id}/outcome says what came of a row.

The columns

The same keys, in this order, on every row, whatever its result; a key that doesn't apply is blank. Keys never change and are only added, at the end. A question's sentence is published once, in questions and in the column's label, never in a key (q1_answer is question 1 whichever words it was asked in). The labels below are for a mission that asks the three example questions.

KeyKindLabelMeaning
refobservablerefYour own key for the row, as you sent it (a row_id, ref or ref_<name> column). Blank when you sent none.
merged_refsobservablemerged_refsYour keys for other rows of your file that were the same business as this one, joined with ;. They were worked once, as this row.
streta_idobservablestreta_idStreta's id for this business on this job. Stable; use it to match a later update of the same row.
jobobservablejobThe name of the job (one wave of a mission over one list) the row belongs to.
companyobservablecompanyThe business as you named it.
phoneobservablephoneThe business's own published line that we rang or would have rung, not the contact's number on your row.
cityobservablecityThe city you sent, or the one the business's listing gave.
domainobservabledomainThe website you sent for this row, as you sent it. Blank when you sent none; the listing's own website is in the context columns.
result_keyobservableresult_keyThe result as a value to route on: matches, no_match or no_answer.
resultobservableresultThe result in words: Matches your objective, Does not match your objective, or No definitive answer.
answered_onobservableanswered_onThe UTC day (YYYY-MM-DD) the answer arrived: the day of our settling call, or of the callback, text or email reply that settled it; for a business we never called, the day we filed the row; for an earlier answer given again (by prior), the day of that earlier answer.
what_we_foundobservablewhat we foundOne sentence of what happened, in plain words, for a person reading the row.
answerobservableanswerThe answer to the qualifying question as it files (yes, no or one of the mission's choices). Blank when there is none.
quoteobservablequoteThe business's own words that carry the answer.
asked_not_to_be_contactedobservableasked_not_to_be_contactedyes when they asked us not to contact them again. Honor it in your own outreach.
instrumentobservableinstrumentWho placed the call: ai or human.
byobservablebyHow the answer arrived, a fact: call (our call's), callback (a call they made to us), text, email (a reply to our email), prior (an earlier answer of ours given again), research (the business's own published words, found before the call, when nobody picked up to confirm or contradict them; see research_words and not_tested), or blank for a business we never called.
your_number_differedobservableyour_number_differedyes when the number on your row was not the business's own line. We ring the listing's line, never a contact's cell.
q1_answerobservable1. do y'all have a waiting area?The answer to question 1 as it files (yes, no, a choice, or the quote for an open question). The question's sentence is in `questions` and in this column's label.
q1_wordsobservable1. their wordsThe business's own words for question 1.
q1_reading_inferredinferred(the question's sentence, once in `questions`)Our reading of question 1, only where the mission asks for one. Inferred: a reading, not a fact.
q2_answerobservable2. are there snacks for customers?The answer to question 2 as it files (yes, no, a choice, or the quote for an open question). The question's sentence is in `questions` and in this column's label.
q2_wordsobservable2. their wordsThe business's own words for question 2.
q2_reading_inferredinferred2. our reading (inferred)Our reading of question 2, only where the mission asks for one. Inferred: a reading, not a fact.
q3_answerobservable3. when are y'all least busy?The answer to question 3 as it files (yes, no, a choice, or the quote for an open question). The question's sentence is in `questions` and in this column's label.
q3_wordsobservable3. their wordsThe business's own words for question 3.
q3_reading_inferredinferred(the question's sentence, once in `questions`)Our reading of question 3, only where the mission asks for one. Inferred: a reading, not a fact.
ended_before_qualifierobservableended before the qualifieryes when the call ended before the qualifying question was asked.
ended_early_wordsobservableended early: their wordsTheir words at the moment the call ended early.
ended_early_reading_inferredinferredended early: our reading (inferred)Our reading of why it ended early. Inferred: a reading, not a fact.
manner_reading_inferredinferredtheir manner: our reading (inferred)Our reading of how they sounded. Inferred: a reading, not a fact.
manner_heardobservabletheir manner: what we heardWhat we heard of their manner, as it happened (rushed, friendly), without a verdict.
called_backobservablecalled us backyes when the business rang us back after the call.
called_back_fromobservablecalled back fromThe number the callback came from.
called_back_from_line_kindobservablecalled back from: line kindWhat kind of line the callback came from (business line, mobile, withheld).
called_back_wordsobservablecalled back: their wordsWhat they said when they called back.
context_statusobservablecontext: statusWhether we read context about the business: found, partial or not read.
context_listingobservablecontext: their listingWhat the business's own listing says (observable).
context_websiteobservablecontext: their websiteWhat the business's own website says (observable).
context_webobservablecontext: elsewhere on the webWhat the web says about the business elsewhere (observable).
context_reviewsobservablecontext: customer reviewsWhat customer reviews say (observable).
context_reading_inferredinferredcontext: our reading (inferred)Our reading of the context. Inferred: a reading, not a fact.
context_could_not_readobservablecontext: could not readWhat we could not read, and why.
stop_requested_onobservablestop requested onThe date they asked us to stop.
stop_request_wordsobservablestop request: their wordsTheir words when they asked us to stop.
no_answer_reasonobservableno answer reasonFor a No definitive answer row, the group of why. One of: answered_not_firm, wrong_person, declined, gated, machine, nobody, stop, questioned, language, nothing_usable, every_channel, not_called.
not_called_reasonobservablenot called reasonFor a row we did not call, the code of why. One of: mobile_not_called, state_law_not_called, no_state_not_called, no_city_or_domain_not_called, business_not_found, answered_before, client_do_not_contact, skipped, capped.
answered_before_streta_idobservableanswered before: streta_idFor a row we did not ask again because this mission already has the business's answer (by prior), the streta_id of the earlier row that answered. Blank otherwise.
answered_by_saidobservableanswered by: what they saidWhat the person who answered said about who they are or why they would know, in their own words. Blank when they said nothing of the kind. A fact: it is what they said, not whether it is true.
would_know_inferredinferredwould they know: our reading (inferred)Our reading of whether the person who answered would know the answer: yes, no or unknown. Inferred: a reading, not a fact; it rests on what they said and how they spoke.
research_wordsobservableresearch: their wordsThe business's own published sentence that says yes to the qualifying question, word for word, found before any call. Blank when its own words did not say it (silence is not a no). A fact: it is what they published, not whether it is true.
research_whereobservableresearch: where it was saidWhose words these are and where: their listing, or their website with the address of the page.
research_callobservableresearch: what the call foundWhat our call found beside the published words, a fact about the call: confirmed, contradicted, not reached, not called, did not settle it. Blank when there are no published words.
not_testedobservablenot testedOn a row that stands on the business's published words alone (by research), what the phone did not test and why. Blank on every other row.
called_back_caller_id_checkobservablecalled back from: caller ID checkWhether the carrier verified the number they called back from (STIR/SHAKEN), in plain words, or "not known". A stop from a number that failed it still stands.
context_job_postsobservablecontext: their job postsWhat the business's own job posts say, with the board, the address, the date posted and the posting's hiring contact (observable).
context_dnsobservablecontext: their DNSThe MX and SPF records the business's own domain publishes, quoted, with the mail provider and sending services they name (observable).

Where a key takes a closed set of values, here they are:

KeyTakes one of
result_keymatches, no_match, no_answer
bycall, callback, text, email, prior, research
research_callconfirmed, contradicted, not reached, not called, did not settle it
instrumentai, human
no_answer_reasonanswered_not_firm, wrong_person, declined, gated, machine, nobody, stop, questioned, language, nothing_usable, every_channel, not_called
not_called_reasonmobile_not_called, state_law_not_called, no_state_not_called, no_city_or_domain_not_called, business_not_found, answered_before, client_do_not_contact, skipped, capped

Of what you sent, a row comes back with ref and merged_refs (your own keys) and domain (the website you sent). Nothing else on your row is returned: join on ref. A row we did not ask because this mission already has the business's answer is by prior, with the earlier answer and its date, and answered_before_streta_id names the earlier row. A row whose answer is the business's own published words, with nobody reached to confirm them, is by research: research_words is their sentence word for word, research_where says where it was said, and not_tested says what the phone could not test. When a call did reach someone, research_call says whether it confirmed or contradicted the published words, and the answer is the call's.

The webhook

Set the URL on your file's settings page; the signing secret is shown once. Each finished row is a POST of JSON. Answer with a 2xx at once and do your work after.

Events

eventWhen
row.finishedA business is finished. One row, with its result.
row.updatedThe same row again because something on it changed after it went out (the context we read about the business). Same sortie.id; replace the earlier row.
row.withdrawnA row we sent is no longer a result. Remove it.
job.finishedA job is over: its file has completed. No row; job (id, name, finished_on) and counts (matches, no_match, no_answer, not_accepted, not_completed). Sent once per address, with the same sequence and version numbering. A workflow with a waiting-for-recon state leaves it on this.
row.sampleThe Send a sample row button: a whole row of sample text, "test": true, never billed and never in your file.

Order and repeats

Deliveries are sent in order per row. sequence is your deliveries in order; version is this row's. Apply a delivery only if its version is greater than the one you hold for that sortie.id. A resend carries the same id, sequence and version.

Retries

If your receiver is down we try again after 1 minute, 5 minutes, 1 hour, 6 hours; a 4xx other than 429 is taken as your bug and isn't retried. A failed delivery is on your settings page and can be resent from there.

Signature

Each request carries Streta-Signature: t=<seconds>,v1=<hex>, where v1 is the HMAC-SHA256, with your secret, of "<t>." + the raw body. Check it over the body exactly as received, and refuse a timestamp more than a few minutes old.

import hashlib, hmac, time

def verify(secret: str, raw_body: bytes, header: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t, v1 = int(parts["t"]), parts["v1"]
    if abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), str(t).encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)

# in your handler: verify(SECRET, request.body, request.headers["Streta-Signature"])
const crypto = require("crypto");

function verify(secret, rawBody, header, tolerance = 300) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split(/=(.*)/s).slice(0, 2)));
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > tolerance) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.`).update(rawBody).digest("hex");
  const a = Buffer.from(expected), b = Buffer.from(parts.v1);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// rawBody is the request body exactly as received (a Buffer), not re-serialized JSON
# the header: Streta-Signature: t=<seconds>,v1=<hex>
printf '%s.%s' "$T" "$(cat body.json)" | openssl dgst -sha256 -hmac "$SECRET" -hex   # compare to v1

A sample row

This is the whole payload of a row, as Send a sample row sends it to your receiver (built from your own mission once you have one).

{
  "id": "5d9c2a40-1111-4222-8333-444455556666",
  "event": "row.sample",
  "test": true,
  "schema": "2026-10",
  "sent_at": "2026-10-20T15:04:05+00:00",
  "client": {
    "id": "c0ffee00-1111-4222-8333-444455556666",
    "name": "Your company"
  },
  "sequence": null,
  "version": null,
  "mission": {
    "id": "a1b2c3d4-1111-4222-8333-444455556666",
    "name": "Sample mission",
    "objective": "Do they have a snack machine for customers?"
  },
  "job": {
    "id": "e5f6a7b8-1111-4222-8333-444455556666",
    "name": "DFW tire shops"
  },
  "sortie": {
    "id": "7c1f2a90-0000-4000-8000-000000000001"
  },
  "questions": [
    {
      "key": "q1",
      "line": "do y'all have a waiting area?",
      "qualifier": false
    },
    {
      "key": "q2",
      "line": "are there snacks for customers?",
      "qualifier": true
    },
    {
      "key": "q3",
      "line": "when are y'all least busy?",
      "qualifier": false
    }
  ],
  "columns": [
    {
      "key": "ref",
      "label": "ref",
      "kind": "observable"
    },
    {
      "key": "merged_refs",
      "label": "merged_refs",
      "kind": "observable"
    },
    {
      "key": "streta_id",
      "label": "streta_id",
      "kind": "observable"
    },
    {
      "key": "job",
      "label": "job",
      "kind": "observable"
    },
    {
      "key": "company",
      "label": "company",
      "kind": "observable"
    },
    {
      "key": "phone",
      "label": "phone",
      "kind": "observable"
    },
    {
      "key": "city",
      "label": "city",
      "kind": "observable"
    },
    {
      "key": "domain",
      "label": "domain",
      "kind": "observable"
    },
    {
      "key": "result_key",
      "label": "result_key",
      "kind": "observable"
    },
    {
      "key": "result",
      "label": "result",
      "kind": "observable"
    },
    {
      "key": "answered_on",
      "label": "answered_on",
      "kind": "observable"
    },
    {
      "key": "what_we_found",
      "label": "what we found",
      "kind": "observable"
    },
    {
      "key": "answer",
      "label": "answer",
      "kind": "observable"
    },
    {
      "key": "quote",
      "label": "quote",
      "kind": "observable"
    },
    {
      "key": "asked_not_to_be_contacted",
      "label": "asked_not_to_be_contacted",
      "kind": "observable"
    },
    {
      "key": "instrument",
      "label": "instrument",
      "kind": "observable"
    },
    {
      "key": "by",
      "label": "by",
      "kind": "observable"
    },
    {
      "key": "your_number_differed",
      "label": "your_number_differed",
      "kind": "observable"
    },
    {
      "key": "q1_answer",
      "label": "1. do y'all have a waiting area?",
      "kind": "observable"
    },
    {
      "key": "q1_words",
      "label": "1. their words",
      "kind": "observable"
    },
    {
      "key": "q1_reading_inferred",
      "label": null,
      "kind": "inferred"
    },
    {
      "key": "q2_answer",
      "label": "2. are there snacks for customers?",
      "kind": "observable"
    },
    {
      "key": "q2_words",
      "label": "2. their words",
      "kind": "observable"
    },
    {
      "key": "q2_reading_inferred",
      "label": "2. our reading (inferred)",
      "kind": "inferred"
    },
    {
      "key": "q3_answer",
      "label": "3. when are y'all least busy?",
      "kind": "observable"
    },
    {
      "key": "q3_words",
      "label": "3. their words",
      "kind": "observable"
    },
    {
      "key": "q3_reading_inferred",
      "label": null,
      "kind": "inferred"
    },
    {
      "key": "ended_before_qualifier",
      "label": "ended before the qualifier",
      "kind": "observable"
    },
    {
      "key": "ended_early_words",
      "label": "ended early: their words",
      "kind": "observable"
    },
    {
      "key": "ended_early_reading_inferred",
      "label": "ended early: our reading (inferred)",
      "kind": "inferred"
    },
    {
      "key": "manner_reading_inferred",
      "label": "their manner: our reading (inferred)",
      "kind": "inferred"
    },
    {
      "key": "manner_heard",
      "label": "their manner: what we heard",
      "kind": "observable"
    },
    {
      "key": "called_back",
      "label": "called us back",
      "kind": "observable"
    },
    {
      "key": "called_back_from",
      "label": "called back from",
      "kind": "observable"
    },
    {
      "key": "called_back_from_line_kind",
      "label": "called back from: line kind",
      "kind": "observable"
    },
    {
      "key": "called_back_words",
      "label": "called back: their words",
      "kind": "observable"
    },
    {
      "key": "context_status",
      "label": "context: status",
      "kind": "observable"
    },
    {
      "key": "context_listing",
      "label": "context: their listing",
      "kind": "observable"
    },
    {
      "key": "context_website",
      "label": "context: their website",
      "kind": "observable"
    },
    {
      "key": "context_web",
      "label": "context: elsewhere on the web",
      "kind": "observable"
    },
    {
      "key": "context_reviews",
      "label": "context: customer reviews",
      "kind": "observable"
    },
    {
      "key": "context_reading_inferred",
      "label": "context: our reading (inferred)",
      "kind": "inferred"
    },
    {
      "key": "context_could_not_read",
      "label": "context: could not read",
      "kind": "observable"
    },
    {
      "key": "stop_requested_on",
      "label": "stop requested on",
      "kind": "observable"
    },
    {
      "key": "stop_request_words",
      "label": "stop request: their words",
      "kind": "observable"
    },
    {
      "key": "no_answer_reason",
      "label": "no answer reason",
      "kind": "observable"
    },
    {
      "key": "not_called_reason",
      "label": "not called reason",
      "kind": "observable"
    },
    {
      "key": "answered_before_streta_id",
      "label": "answered before: streta_id",
      "kind": "observable"
    },
    {
      "key": "answered_by_said",
      "label": "answered by: what they said",
      "kind": "observable"
    },
    {
      "key": "would_know_inferred",
      "label": "would they know: our reading (inferred)",
      "kind": "inferred"
    },
    {
      "key": "research_words",
      "label": "research: their words",
      "kind": "observable"
    },
    {
      "key": "research_where",
      "label": "research: where it was said",
      "kind": "observable"
    },
    {
      "key": "research_call",
      "label": "research: what the call found",
      "kind": "observable"
    },
    {
      "key": "not_tested",
      "label": "not tested",
      "kind": "observable"
    },
    {
      "key": "called_back_caller_id_check",
      "label": "called back from: caller ID check",
      "kind": "observable"
    },
    {
      "key": "context_job_posts",
      "label": "context: their job posts",
      "kind": "observable"
    },
    {
      "key": "context_dns",
      "label": "context: their DNS",
      "kind": "observable"
    }
  ],
  "row": {
    "ref": "crm-1042",
    "merged_refs": "crm-1077;crm-1090",
    "streta_id": "7c1f2a90-0000-4000-8000-000000000001",
    "job": "DFW tire shops",
    "company": "Cowtown Tire",
    "phone": "+18175550142",
    "city": "Fort Worth",
    "domain": "cowtowntire.example",
    "result_key": "matches",
    "result": "Matches your objective",
    "answered_on": "2026-10-20",
    "what_we_found": "Spoke with someone at the shop: no snack machine.",
    "answer": "no",
    "quote": "No, we just have a coffee pot.",
    "asked_not_to_be_contacted": "",
    "instrument": "ai",
    "by": "call",
    "your_number_differed": "yes",
    "q1_answer": "yes",
    "q1_words": "Sample: the business's words for q1.",
    "q1_reading_inferred": "Sample: our reading for q1. Inferred.",
    "q2_answer": "no",
    "q2_words": "Sample: the business's words for q2.",
    "q2_reading_inferred": "Sample: our reading for q2. Inferred.",
    "q3_answer": "no",
    "q3_words": "Sample: the business's words for q3.",
    "q3_reading_inferred": "Sample: our reading for q3. Inferred.",
    "ended_before_qualifier": "no",
    "ended_early_words": "Sample: words when a call ended early.",
    "ended_early_reading_inferred": "Sample: our reading of why it ended early.",
    "manner_reading_inferred": "Sample: our reading, relaxed and helpful.",
    "manner_heard": "Sample: unhurried, answered at once.",
    "called_back": "no",
    "called_back_from": "+18175550199",
    "called_back_from_line_kind": "business line",
    "called_back_words": "Sample: returning your call, we don't have one.",
    "context_status": "found",
    "context_listing": "Sample: independent tire shop, open 8 to 6, waiting area.",
    "context_website": "Sample: family owned since 1987.",
    "context_web": "Sample: mentioned in a local list of tire shops.",
    "context_reviews": "Sample: customers mention a clean waiting room.",
    "context_reading_inferred": "Sample: our reading of the context.",
    "context_could_not_read": "",
    "stop_requested_on": "",
    "stop_request_words": "",
    "no_answer_reason": "",
    "not_called_reason": "",
    "answered_before_streta_id": "Sample: our reading for an. Inferred.",
    "answered_by_said": "Sample: our reading for an. Inferred.",
    "would_know_inferred": "Sample: our reading for wo. Inferred.",
    "research_words": "Sample: the business's words for re.",
    "research_where": "Sample: our reading for re. Inferred.",
    "research_call": "Sample: our reading for re. Inferred.",
    "not_tested": "Sample: our reading for no. Inferred.",
    "called_back_caller_id_check": "Sample: our reading for ca. Inferred.",
    "context_job_posts": "Sample: our reading for co. Inferred.",
    "context_dns": "Sample: our reading for co. Inferred."
  },
  "inferred_columns": [
    "q1_reading_inferred",
    "q2_reading_inferred",
    "q3_reading_inferred",
    "ended_early_reading_inferred",
    "manner_reading_inferred",
    "context_reading_inferred",
    "would_know_inferred"
  ],
  "message": "This is a sample row from Streta. It isn't a business, it isn't billed and it isn't in your file."
}

A recipe per tool

Clay

Export the table as a CSV and send it as above, or send it from a script on a schedule. To receive rows, add a table with a Webhook source in Clay and put its URL in your Streta file settings: each row.finished arrives as one record and the fields are under row. Clay can't check the signature, so keep the URL secret.

Zapier

Trigger: Webhooks by Zapier, Catch Hook; paste its URL into your Streta file settings and press Send a sample row to teach Zapier the fields. Zapier can't verify the signature; keep the URL secret. Use row.result_key to filter (matches, no_match, no_answer) and row.ref to find your own record.

Make

Scenario with a Custom webhook module; paste its address into your file settings and press Send a sample row so Make learns the structure. Filter on row.result_key; route row.updated and row.withdrawn events to update or delete the record that carries the same sortie.id.

n8n

A Webhook node (POST, respond immediately with 200). To verify the signature, add a Code node that runs the Node.js function above over the raw body (set the Webhook node's Raw Body option).

A script or your own server

Receive POST requests, verify the signature with the function above, answer 2xx at once, and do your work after you have answered. Key your records on sortie.id and apply a delivery only if its version is greater than the one you hold.

What changes

The schema is 2026-10. A key is never renamed or removed and a new one is added at the end of row, so a receiver that reads the keys it knows keeps working. A change that cannot be made that way gets a new schema value, announced here before it is sent.

Questions: hello@streta.ai.