Contact resolver
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
A slow, unreachable, or malformed answer degrades to a call with no contact and no memory. Never a failed call, never a delayed one.
A few hundred milliseconds, measured from dial to last byte, and not configurable. It sits in front of first audio.
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
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
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.
Python
TypeScript
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
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
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.
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”.
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.