Contact resolver

An endpoint you host that tells us who is calling, before the agent speaks its first word

An inbound call arrives from +1 202 555 0100. We know the number, and nothing else - a carrier vouching for a number says nothing about which of your users it belongs to, and we will not guess. So the agent opens with “can I take your name?”, or you pre-sync your entire user base before your first call.

The contact resolver is the third option: at the start of the call we ask you. You register one HTTPS endpoint for your workspace; we POST the caller’s number to it, signed; you answer with your own user id. The contact attaches before the first token is generated, so {{memory}} is populated on the opening line and the agent can say “Hi Sarah, is this about Tuesday’s order?”.

Your answer is an assertion from your authenticated backend, so it is recorded exactly like a link you make through the Contacts API: asserted_by: api, with the caller’s number linked to the same person.

The three properties to design against

It fails open

A slow, unreachable, or malformed answer degrades to a call with no contact and no memory. Never a failed call, never a delayed one.

The deadline is ours

A few hundred milliseconds, measured from dial to last byte, and not configurable. It sits in front of first audio.

The answer is narrow

Identity, an optional name, an optional variable map. Anything else is rejected - including fields we do not recognise.

Your endpoint being down is a normal condition here, not an incident on our side. Design for it: return what you have quickly, and answer “I don’t know them” rather than blocking on a slow lookup.

Configure it

One resolver per workspace, so it is workspace state rather than a resource you create: set it on /v1/workspaces/current/contact-resolver - PUT the URL, and the response carries a signing secret once:

$curl -X PUT https://api.sws.speechify.com/v1/workspaces/current/contact-resolver \
> -H "Authorization: Bearer $SPEECHIFY_API_KEY" \
> -H "Content-Type: application/json" \
> -d '{"url": "https://crm.example.com/speechify/contact-resolver"}'
1{
2 "url": "https://crm.example.com/speechify/contact-resolver",
3 "disabled": false,
4 "secret": "crsec_...",
5 "created_at": "2026-08-12T09:15:00Z",
6 "updated_at": "2026-08-12T09:15:00Z"
7}

Store the secret; it is not shown again. Later edits keep it (so changing the URL never breaks your verifier) and return it blank. rotate-secret on the same resource mints a new one - deploy it to your verifier first, because the old secret stops signing immediately. "disabled": true parks the integration without losing the secret, and deleting the resource stops the callback at the next call.

The URL must be https and must resolve to a public address. Loopback, private-range and cloud-metadata hosts are rejected when you set them, and blocked again at dial time.

What we send

1POST /speechify/contact-resolver HTTP/1.1
2Host: crm.example.com
3Content-Type: application/json
4User-Agent: Speechify-Contact-Resolver/1.0
5Speechify-Signature: t=1755000000,v0=5257a869e7ecebeda32affa62cdca3fa793333ac6e5b9e6b1c...
1{
2 "channel": "voice",
3 "identifier": { "kind": "phone", "value": "+12025550100" },
4 "agent_id": "agent_01jqr8x9zg5k2m3n4p5q6r7s8t",
5 "conversation_id": "conv_01jqr8x9zg5k2m3n4p5q6r7s8t"
6}
FieldMeaning
channelThe surface the identifier arrived on. voice today.
identifier.kindphone, external_id, or email. Inbound calls send phone.
identifier.valueThe handle. For an inbound call, the caller’s E.164 number.
agent_idThe agent answering.
conversation_idThe call your answer attaches to; useful for correlating with webhooks and logs later.

Verify the signature

Speechify-Signature is the same scheme every Speechify webhook uses, so if you already verify our webhooks you can reuse that code. The HMAC-SHA256 is computed over <t>.<raw_request_body> with your resolver’s secret, where t is the unix-seconds value in the header.

1import hashlib
2import hmac
3import time
4
5def verify(raw_body: bytes, header: str, secret: str) -> bool:
6 parts = dict(p.split("=", 1) for p in header.split(","))
7 timestamp, signature = parts["t"], parts["v0"]
8
9 # Reject stale deliveries; 5 minutes is our recommended window.
10 if abs(time.time() - int(timestamp)) > 300:
11 return False
12
13 expected = hmac.new(
14 secret.encode(),
15 f"{timestamp}.".encode() + raw_body,
16 hashlib.sha256,
17 ).hexdigest()
18 return hmac.compare_digest(expected, signature)

Verify against the raw body bytes, before any JSON parse-and-re-serialize; re-encoding changes the bytes and the signature will not match.

What you answer

1{
2 "external_id": "acme_user_42",
3 "display_name": "Sarah Chen",
4 "variables": { "plan": "gold", "renewal_date": "2026-09-01" }
5}
FieldRequiredEffect
external_idyesYour id for this person. Recorded as an external_id identifier, and the caller’s number is linked to the same contact. It is also the key the call’s long-term memory is read from and written back to.
display_namenoNames the contact if it has none yet. It never overwrites a name you set through the API. Max 256 bytes.
variablesnoUp to 32 string values, each at most 1024 bytes, available in the prompt as {{plan}}, {{renewal_date}}, and so on. Reserved system__* keys are refused.

If you do not recognise the caller, say so: answer 204 No Content, or {"external_id": ""}. That is a normal answer, and it is cached briefly - a repeat dial or a caller loop will not hammer your backend with the same question.

The response is decoded strictly. An unrecognised field rejects the whole answer and the call proceeds with no contact - it is not ignored. This is deliberate: the resolver answers one question, identity, which has one answer and a safe degrade. It is not a hook for returning prompt, voice, tools, or other session configuration. Those have working defaults here, and routing them through your endpoint would make every call depend on your uptime.

Caching

Answers are cached briefly per (workspace, identifier kind, identifier value) - positive answers for a few minutes, “I don’t know them” for about a minute. A repeat caller inside that window does not reach your endpoint at all.

That means an identity correction on your side takes effect within minutes rather than instantly. If you need a link to apply immediately, make it through the Contacts API on the /v1/contacts/{contact_id}/identifiers sub-resource; a link recorded there is stored, not cached.

Changing the resolver itself is instant, though: an answer is cached against the configuration that produced it, so the moment you PUT a new URL the endpoint you replaced stops answering. You never have to wait out a window for a CRM you have already migrated off.

What happens on the call

1

The call arrives

We create the conversation and, per our identity rules, a contact carrying only the caller’s number - we still do not know who they are.

2

We ask you

The signed request goes out with the deadline running. A cached answer skips this step.

3

We record your answer

The number is linked to the person you named, at api provenance, and the conversation is pointed at them.

4

The agent opens

The prompt is rendered with that person’s memory block and your variables, then the call starts.

The phone-only contact from step 1 stays. Merging it into the person is a deliberate act with its own endpoint, because a wrong merge moves one person’s memories onto another - see Contacts for how memory is scoped.

When it degrades

A degrade is not an error you will see anywhere in the call: the call connects, the agent speaks, and {{memory}} renders empty. On our side each one is logged and metered with a reason, so support can tell “your endpoint is slow” from “your endpoint is returning a shape we reject”.

What happenedWhat the caller experiences
Your endpoint took longer than the deadlineA normal call with no contact
Your endpoint returned 4xx or 5xxA normal call with no contact
The body was malformed or carried unknown fieldsA normal call with no contact
The URL no longer resolves to a public addressA normal call with no contact
You answered 204 / an empty external_idA normal call with no contact

If your callers are consistently not being recognised, check in this order: the endpoint is reachable from the public internet over HTTPS, it answers well inside the deadline, the response carries only the three documented fields, and your handler is not rejecting our signature.