Skip to navigation

Verify SWML request signatures

Confirm that a request for a SWML document, or from an AI agent, really came from SignalWire.
View as MarkdownOpen in Claude

When you serve SWML from your own web server, anyone who learns your endpoint URL can POST to it and read back the SWML document you return. Since a SWML document can contain phone numbers, SIP credentials, and prompts, that endpoint should not answer to just anyone.

To let you check the caller, SignalWire signs every request for a SWML document with an HMAC signature derived from your project’s signing key. Verifying that signature proves the request came from SignalWire and that neither the URL nor the body was altered in transit. The requests an AI agent sends to your server, such as SWAIG function calls and the post-prompt summary, carry the same signature.

There is no setting to enable signing. Every project has a signing key, and SignalWire signs these requests with it automatically. Your server decides whether to check the signature. In the rare case SignalWire can’t compute a signature, it sends the request unsigned, as described in Plan for unsigned requests, redirects, and retries.

This step is not optional!

For production applications it is extremely important to verify the signature, so that requests from a malicious third party are rejected instead of being served a SWML document.

Serve the document over HTTPS as well. SignalWire accepts an http:// URL, but the document, and any credentials in it, then travels unencrypted. To require a password as well as a signature, put basic auth credentials in the URL you configure, as in https://username:password@example.com/swml. SignalWire removes them from the URL and sends them in an Authorization header when it fetches the document.

Which requests are signed

Every POST SignalWire makes to fetch a SWML document from a URL you control is signed. That includes:

  • The initial fetch, when a Resource or phone number is configured with an External URL rather than a hosted script, and the fetch from your fallback URL when the primary one fails.
  • Every subsequent fetch caused by execute or transfer pointing at an external URL.
  • The same fetches on the messaging side, when a number handles inbound SMS and MMS with Messaging SWML.
  • Every request an AI agent makes during a call: the POST to each SWAIG function’s web_hook_url, including one set in SWAIG.defaults, the webhooks in a function’s data_map, the function-definition request to each SWAIG.includes URL, the summary sent to post_prompt_url, the request to post_prompt_url that loads a stored conversation when the agent sets save_conversation and a conversation_id, and the live stream sent to debug_webhook_url.
  • Function webhooks from ai_sidecar and amazon_bedrock.

Requests during a call, AI agent requests included, carry two headers. Requests for a messaging document carry the SHA-1 header alone, so verify that one if your endpoint serves both.

HeaderAlgorithmSent on
X-Signalwire-SignatureHMAC-SHA1, hex encodedEvery signed request
X-Signalwire-SHA256-SignatureHMAC-SHA256, hex encodedCall requests

Both are computed over the same string: the request URL concatenated directly with the raw request body, with no separator.

signature = hex( HMAC( signing_key, url + raw_body ) )

The url is the full URL SignalWire requested, including any query string. A call request, AI agent requests included, signs that URL without any basic auth credentials you embedded in it; a message request signs it exactly as you configured it, credentials included. The raw_body is the JSON payload exactly as sent. For a SWML document, that’s the object containing call (or message for a messaging document), vars, envs, and, when the document was reached through execute or transfer, params. For an AI agent request, it’s the JSON the agent posts, such as the SWAIG function call or the post-prompt summary. A data_map webhook is the exception: its body is the JSON built from the webhook’s params, form-encoded instead when the webhook sets form_param. When a data_map webhook has no body, as with a GET, the signature covers the URL alone.

Verify against the URL you configured, in the Dashboard or in your SWML, not the URL your framework reconstructs from the incoming request. Proxies, load balancers, and tunnels such as ngrok routinely rewrite the host or scheme, which changes the string being hashed and makes a valid signature look invalid. For a call request, remove any credentials embedded in the URL first.

When the query string changes from call to call, as it does when the Server SDKs add a per-call token to an agent’s webhook URLs, you can’t hard-code the URL. Rebuild it from your public base URL plus the path and query string of the request you received.

Which requests are not signed

A few requests a SWML document can send are not signed. An endpoint that insists on a signature rejects them, so protect these endpoints with basic auth or a shared secret instead.

The request method is unsigned when a call runs it. Use basic auth in its URL, or send a shared secret in a custom X- header through the method’s headers parameter. Its messaging counterpart does carry the SHA-1 header.

An amazon_bedrock agent signs its function webhooks, but not the summary it sends to post_prompt_url or the request for its summarize_conversation function. Embed basic auth credentials in those URLs, in the form username:password@url.

Plan for unsigned requests, redirects, and retries

If SignalWire can’t compute a signature for a request, it sends the request without the signature headers rather than not sending it. An endpoint that requires a signature rejects that request, and the fetch or function call it carried fails. Treat a missing signature the same as an invalid one.

Signed AI agent requests don’t follow redirects. A signature covers the exact URL it was computed for, so a 3xx response to a signed AI agent request counts as a failed request instead of being followed. An unsigned request, such as the amazon_bedrock post-prompt summary or a request sent without a signature for the reason above, does follow a redirect. Point web_hook_url, post_prompt_url, and the agent’s other URLs at their final address, including the https:// scheme and any trailing slash your server would otherwise redirect to.

When an AI agent’s POST to a SWAIG function’s web_hook_url, to post_prompt_url, or to another of its webhook URLs fails to connect, times out, or gets a 5xx response, SignalWire retries it with the same body and the same signature. data_map webhooks are not retried. A signature identifies the content of a request, not one delivery, so don’t reject a request only because you’ve seen its signature before.

Because a timeout triggers a retry, a slow handler can receive the same function call again while it is still processing the first. Make your handlers idempotent, so that running a function twice has the same effect as running it once.

Get your signing key

Your signing key is on the API Credentials page of your Dashboard. Click Show to reveal it. Each project has its own key, so use the key belonging to the project that serves the call.

The API Credentials page in a SignalWire Space showing the signing key

The Signing Key on the API Credentials page

You can rotate the key with the reset button on the same page. A new key takes about a minute to become active, and the page shows it to you before you confirm the reset so you can copy it into your application first.

Treat the signing key like a password: keep it in an environment variable or a secret manager, not in the source you deploy.

Verify signatures with the Server SDKs

Agents built with the Server SDKs check signatures for you. Give the agent your signing key, and it verifies every POST to its SWML, SWAIG, and post-prompt endpoints, answering an unsigned or mis-signed request with 403 Forbidden. Pass the key when you create the agent, or set it in the SIGNALWIRE_SIGNING_KEY environment variable and the agent reads it from there.

import os
from signalwire import AgentBase
agent = AgentBase(
name="support",
route="/support",
signing_key=os.environ["SIGNALWIRE_SIGNING_KEY"],
)
agent.prompt_add_section("Role", "You are a friendly support agent.")
agent.run()

Install the latest Server SDK to get signature checking. Without a signing key, the agent logs a warning at startup and accepts unsigned requests.

The signature check works alongside the agent’s other protections: the basic auth credentials the SDK adds to every webhook URL it generates, and the per-function token on functions marked secure. Server SDK security covers all three.

Behind a reverse proxy or a tunnel such as ngrok, set SWML_PROXY_URL_BASE to your public base URL, for example https://example.ngrok.io. The agent then checks the signature against the URL SignalWire requested, not the one the proxy forwarded to it.

Verify the signature in Node

The validateRequest helper in @signalwire/web-api implements the check for you:

npm install @signalwire/web-api

validateRequest needs the raw request body, so capture it before your JSON parser consumes it. Re-serializing the parsed object is not reliable — key order and whitespace change, and the hash changes with them.

index.js
const express = require("express");
const { validateRequest } = require("@signalwire/web-api");
const app = express();
// Keep the raw body around for signature verification.
app.use(
express.json({
verify: (req, _res, buf) => {
req.rawBody = buf.toString();
},
})
);
// The public-facing URL you configured in the Dashboard.
const WEBHOOK_URL = "https://example.ngrok.io/start";
app.post("/start", (req, res) => {
const valid = validateRequest(
process.env.SIGNALWIRE_SIGNING_KEY,
req.headers["x-signalwire-signature"],
WEBHOOK_URL,
req.rawBody
);
if (!valid) {
return res.status(403).send("Invalid signature");
}
res.send(`
sections:
main:
- play:
url: 'say:Hello from SignalWire!'
`);
});
app.listen(3000);

Verify the signature in any language

The scheme is a plain hex HMAC, so you can implement it directly wherever a helper is not available. Compare digests with a constant-time comparison rather than string equality.

import hmac
import hashlib
import os
from flask import Flask, request, Response
app = Flask(__name__)
# The public-facing URL you configured in the Dashboard.
WEBHOOK_URL = "https://example.ngrok.io/start"
def signature_is_valid(url, raw_body, header):
expected = hmac.new(
os.environ["SIGNALWIRE_SIGNING_KEY"].encode(),
(url + raw_body).encode(),
hashlib.sha1, # hashlib.sha256 for X-Signalwire-SHA256-Signature
).hexdigest()
return hmac.compare_digest(expected, header or "")
@app.route("/start", methods=["POST"])
def start():
raw_body = request.get_data(as_text=True)
header = request.headers.get("X-Signalwire-Signature")
if not signature_is_valid(WEBHOOK_URL, raw_body, header):
return Response("Invalid signature", status=403)
return Response(
"""
sections:
main:
- play:
url: 'say:Hello from SignalWire!'
""",
mimetype="text/plain",
)

To verify the stronger header on a call request, hash the same url + raw_body string with SHA-256 and compare it against X-Signalwire-SHA256-Signature.

Confirm your endpoint rejects forgeries

With the server running and reachable at the URL you configured, POST to it yourself, without a signature:

curl -i -X POST https://example.ngrok.io/start \
-H "Content-Type: application/json" \
-d '{}'

The response is 403 Forbidden, with Invalid signature as the body. Now place a call to the number pointed at that URL: this request carries a valid signature, passes the check, and the caller hears the prompt your document plays.

If the real call is rejected too, the WEBHOOK_URL in your code doesn’t match the URL SignalWire requested — an ngrok URL that changed when the tunnel restarted is the common cause. The next section covers the rest.

Troubleshoot a failing signature

A signature that never validates almost always comes down to one of these:

  • The URL does not match. Scheme, host, port, path, and query string all feed the hash. Use the exact URL from the Dashboard or your SWML, including its query string. When the query string varies per call, rebuild the URL from your public base URL plus the path and query string of the request you received.
  • The body was re-serialized. Hash the bytes you received, not JSON.stringify of the parsed object.
  • The wrong project’s key. Signing keys are per project.
  • The key was just rotated. A new key needs about a minute to become active.
  • Basic auth in the URL. A call, AI agent requests included, strips embedded credentials before signing, so hash the URL without them. A message signs the URL as configured, so hash it with them.
  • A redirect. A signed AI agent request isn’t redirected, so a webhook URL that answers with a 3xx fails. A SWML document fetch does follow a redirect, but its signature still covers the URL you configured, not the one it was redirected to.

Next steps