Webhook SSRF, closed by a sender that holds nothing
Webhook SSRF closed twice: the process with your secrets never sends, and the process that sends can reach only the public internet.

Updated 14 September 2026 with request signing, the tenant gate, figures, and what we found when we checked the code against the claims.
When an agent on a Talkif call decides to call your API — a voice agent webhook — two different programs are involved, and neither one is trusted with the whole job; that split is how we close webhook SSRF. The first — the backend that runs your account — looks up the function, merges what the language model asked for with what the call already knows, decrypts your API key, builds the complete HTTP request and signs it. The second — a small executor with no database, no secrets and no permission to reach anything inside our network — receives that finished request, sends it once, and hands back whatever came back. The executor cannot be redirected, cannot follow a hostname to a private address, cannot read more than a megabyte of response, and cannot wait longer than 35 seconds. The backend, which could do all of those things, never makes the call.

The model supplies arguments, not the request
A flow function is defined in the dashboard as a structured HTTP request: a URL with path placeholders, a method, headers, and a set of parameters, each marked by where it goes — path, query or body. That definition is what the backend reads at execution time, from the database, by the function's id. Nothing about the request shape comes from the model or from the bot that is running the call. That matches what a tool call is at the provider: in OpenAI's words, "a special kind of response we can get from the model if it examines a prompt, and then determines that in order to follow the instructions in the prompt, it needs to call one of the tools we made available to it" (OpenAI, Function calling) — a request to act, not an act. The bot sends a function name and an argument map, and the backend verifies that the call the arguments belong to is owned by the account that is asking for it before it reads anything else.
Each parameter also carries a source. A parameter the language model is meant to fill comes from the model. A parameter bound to the call — the caller's number, the contact's id, a campaign field — comes from the call context. A static parameter comes from the function definition itself. When the backend merges the model's arguments with the other two, context and static values always win: whatever the model supplied for a bound parameter is discarded before validation. A caller who spends a minute convincing the agent that their customer id is someone else's gets a request built from the id the call was placed with. The merged arguments are validated against the composed schema, split by location, and path values are percent-encoded per segment, so a value of ../admin stays a single path segment rather than climbing out of it.
Only then are the customer's headers decrypted — they are stored encrypted at rest, and the key never leaves the backend — and the request is assembled: final URL, method, headers, query parameters and body as separate fields. That object, and nothing else, crosses to the executor.
The executor can reach the internet and nothing else
The executor is one small program. It has no database connection, no credential store, no cloud role and no permissions on the cluster it runs in; it runs as an unprivileged user on a read-only filesystem, and its only inbound channel is the internal queue it takes work from. Its job is to be the part that can be wrong without anything leaking.
Before it opens a connection it parses the URL and accepts only http and https. It resolves the hostname and checks every address the name returns — not the first one — against the ranges that must never be reached from inside a cloud network: loopback, the three private IPv4 blocks, link-local including the instance-metadata address, the carrier-grade NAT range, and their IPv6 equivalents including IPv4-mapped addresses. If any one address is blocked, the request is refused. OWASP's SSRF prevention guidance describes the same check — "the application will retrieve all the IP addresses behind the domain name provided (taking records A + AAAA for IPv4 + IPv6) and it will apply the same verification" — and adds the second rule we follow: "disable the support for the following of the redirection in your web client in order to prevent the bypass of the input validation" (OWASP Cheat Sheet Series). The executor follows no redirects; a 302 to an internal address is returned to the backend as a status code and goes nowhere.
That check is a check-then-connect, and we should be precise about what it does not close. The HTTP client resolves the name a second time when it connects, so a name that flips from a public address to a private one between the two lookups would pass the code. What closes that is the second layer: a network policy enforced by the kernel on the executor's pod that allows outbound traffic to the internet and denies the same private ranges regardless of what DNS said. OWASP again: network segregation "is highly recommended in order to block illegitimate calls directly at network level itself". The code check exists to give a clear, logged refusal for the ordinary case; the policy exists for the case the code cannot see.

The rest of the executor's rules are about not being made to do too much. Request bodies and response bodies are each capped at 1 MB. The timeout is whatever the function asked for, clamped between 100 ms and 35 s, with a separate 10 s limit on establishing the connection. Methods are GET, POST, PUT, PATCH and DELETE; anything else is a permanent error. It runs at most 50 requests at once and, when full, refuses immediately rather than queueing — the backend is told, and the agent gets an error it can speak to. On shutdown it stops taking work, waits up to 25 s for requests in flight to finish, and then disconnects, so a deploy does not cut a customer's request off mid-flight.
Webhook signing covers the bytes on the wire
Since June 2026 every request to a customer endpoint can carry a Talkif-Signature header: a timestamp and an HMAC-SHA256 over "{t}.{body}", keyed with a per-account secret that you can rotate with an overlap window, during which both the current and the previous signature are sent. The receiving side is documented at docs.talkif.ai/integrate/webhooks, with the two rules that matter: verify against the raw request bytes, and compare in constant time. The scheme is deliberately the one Stripe's webhooks use, so the reasoning in their documentation applies unchanged: "Without verification, an attacker could send fake webhook events to your endpoint to trigger actions like fulfilling orders, granting account access, or modifying records", and on the timestamp, "Because this timestamp is part of the signed payload, it's also verified by the signature, so an attacker can't change the timestamp without invalidating the signature" (Stripe, Receive Stripe events in your webhook endpoint). Reject a t older than a few minutes and a replayed request is dead on arrival. The backend also strips any header a flow author added that collides with the signature header, case-insensitively, so a definition cannot inject a fake one.
The engineering problem in signing is not the HMAC. It is that the backend signs a body it does not transmit: the executor serialises the body and sends it. If the two programs ever serialised the same JSON object differently — key order, whitespace, the handling of an empty body — the signature would be over one sequence of bytes and the wire would carry another, and every customer's verification would fail behind a wall of HTTP 200s, because the request itself would have gone through fine. The two programs pin the same JSON library configured to emit keys in sorted order, the backend's decision about when a body is sent at all mirrors the executor's, and a test in the executor's CI asserts both facts against the backend's predicate. The comment on that test says what it is for: CI fails there, loudly, instead of every customer's signature verification failing silently.

It is sent once, and the agent hears the result
There is no retry anywhere in this path. The executor sends once. The backend waits for the reply, with a timeout five seconds longer than the function's own. The bot waits 35 seconds for the backend. A request that fails is classified — retryable (timeout, connection refused, 429, 502–504) or permanent (4xx, blocked address, oversize, bad scheme) — and that classification is returned, but nobody acts on it yet.
That is a choice, not a gap. A person is on the phone. A retry with backoff that takes eight seconds is eight seconds of silence in a conversation; three of them are a hang-up. So the failure goes back to the agent as the result of the tool call — an error object in place of the response — and the node transition the flow author wired still happens, so the model sees the error in the new node and can say so, or try something else, or offer a callback. The classification is there for the callers that can afford to retry: event webhooks and alert webhooks that run outside a live call, which the executor's request format already names as future sources.
Two honest notes on what the dashboard shows. The call timeline records each function by name and duration; it does not today show whether the call succeeded — that is visible in the transcript if the agent said so, and in our logs. And this is a path that was, until recently, lightly used: across production logs from 14 August to 12 September 2026 there were two server-side function executions. The design was built for the traffic we expect, and it has not yet been tested by volume.
Developers
Turn detection that doesn't talk over you
How our voice agent's turn detection decides a caller has finished, when an interruption is real and when to stay quiet, and the incidents behind it.
Integrations
Meta lead ads, called on arrival and reported back
Connect Meta lead ads to Talkif: each lead is called within your calling hours, and received, contacted, qualified and converted go back to Meta.
Developers
Voice agent function calling with bound parameters
Voice agent function calling on Talkif: describe the endpoint once, then bind each parameter to the model, the call or a fixed value no caller can spoof.
Developers
What the flow builder draws is compiled twice
The flow builder canvas is compiled twice: publish resolves, validates and flattens it into nodes and tools; call start binds the caller.
Questions about this piece? Write to us.



