Turn an undelivered or failed message status callback into one outbound call that speaks the same message, once per message and only when SignalWire signed the callback.
Also called SMS fallback to voice, text to call fallback, undelivered SMS handling, voice notification when SMS fails
webhooksoutboundrest
The claim
A text can fail quietly: a landline, a blocked number, a carrier rejection. The status callback you set when sending says so, with status set to undelivered or failed, and the spec requires to and body on that payload. That is everything a follow-up call needs. The handler places one call with the message spoken inline: answer, play a say: of the body, hangup.
Why it holds
Three guards make it safe to run unattended, and each one is a way this otherwise loses money.
How it works
FALLBACK_ON = {"undelivered", "failed"}
def spoken(body):
"""One line. A newline in the body would fail the play url's own pattern."""
return "We could not reach you by text. The message was: " + " ".join(body.split())
def handle(event):
if event.get("status") not in FALLBACK_ON:
return {"called": False, "reason": str(event.get("status") or "none")}
message_id, to = event.get("id"), event.get("to")
if not message_id or not to:
return {"called": False, "reason": "no message id" if not message_id
else "no recipient"}
if not claim(message_id): # atomic, before anything is spent
return {"called": False, "reason": "already handled"}
try:
call = place_call(to, event.get("body") or "")
except Exception:
release(message_id) # nothing was placed, so let it retry
raise
return {"called": True, "call_id": str(call.get("id") or "")}
*The signature.* The route checks it before anything else, because a webhook that spends money on any POST is an open invitation. The check is the one from verify-a-webhook-signature: HMAC over the callback URL plus the raw body, SHA-256 header preferred, hex compared in constant time. The URL is the status_callback you configured, with the request’s own query appended. The configured query is stripped first, so a tagged URL like .../message-status?source=orders is signed once rather than twice.
*The claim.* claim() creates one marker file per message id with "x", which is O_EXCL, so two deliveries arriving at once cannot both win it. It runs before the dial, and the dial’s failure releases it. The spec calls these callbacks “advisory, best-effort notifications” whose “delivery can be delayed or fail silently”. The handler treats arrival as a hint, not a promise.
*The single line.* The bundled schema’s play_url pattern is anchored and its . does not match a newline, so a two-line body would render a document the platform refuses. spoken() collapses whitespace, which is why a pickup notice with an address on its own row still places a call.
dial with swml puts the whole call in the request, so nothing has to be fetched from you when the callee answers. The spec’s Calling.CallCreateParamsSWML variant requires from and swml, and every param this recipe sends is in its property list.
Limitations
The verifier proves the requests, not the ring. Whether a call is answered, and whether a machine picks up, is a live question; detect-an-answering-machine is the next step for the machine case.
The customer sees VOICE_FROM, not the number that texted them. A voice call needs a voice-capable number, and the two are often different; put a number they recognise there.
The compat send (StatusCallback) posts a different payload, form-encoded, with MessageStatus, To and Body. This handler reads the REST shape only; the compat one is a field-name change in handle.
The marker directory is a stand-in for a unique key in your table, and it never expires. A call placed for a message is a cost, so that key is the thing to keep.
A dial whose response never arrives is not retried. A request that reached SignalWire and lost its response looks like one that never arrived. The claim stays, so at most one call goes out per message. The marker is where a reconciler looks; reconcile-webhooks-against-the-logs-api finds out what really happened.
What to change first
Add "sent" to FALLBACK_ON and run the verifier. The sent row fails, because a text that merely left the platform is not a text that failed. Every recipient would get a phone call for a message still on its way.