Webhook Integration for Developers

PhoneShield team
The people who build and run PhoneShield
Written and kept current by the team behind PhoneShield, a sister company of Byte Federal, whose staff spent years meeting scam victims at cryptocurrency ATMs with the scammer still on the line.
In this article
Introduction
PhoneShield answers a customer’s forwarded phone. Known scam numbers are refused before answering, favorites ring through, and everyone else meets the front desk, which rings the owner, hands the call to the assistant Nora, takes a message, or declines. A message and an assistant call each produce a note for the owner; the webhook delivers that note to a URL you own, as JSON, once the call has ended. One webhook per account, included for everyone, carrying exactly what Telegram, email and Slack carry.
In one paragraph
PhoneShield makes an HTTP POST (or PUT, if you chose it) with a JSON body: a title, a message whose lines hold the summary, caller number, callback number and reason, an ISO timestamp, the call id, and the outcome. Every request is signed with HMAC-SHA256 under your signing secret, waits up to ten seconds, and is retried once after a 5xx or 429. You verify the signature, answer 200, and do what you like with it.
No REST API
There is no polling API for calls. The webhook and the dashboard are the two ways to get at what happened; for history, keep what the webhook sends.
What people build with it
A shared inbox or ticket
Your own CRM
A spreadsheet log
Alerts that fit you
Setting Up the Webhook
- Open Integrations in your dashboard
Find the Webhook card and click Connect (or Edit settings if already connected).
- Enter your endpoint URL
An HTTPS URL with a valid certificate that accepts a JSON body and answers within ten seconds. HTTP Method is POST unless you pick PUT. Whatever you type in Authorization Header (for example Bearer abc123) is sent verbatim as the Authorization header. Include the full transcript in the payload adds a call object with the transcript; leave it off unless you need it.
- Save integration, then Send test
The first save generates a signing secret, shown read-only under Edit settings; copy it into your receiver. The card shows as Configured, and PhoneShield calls that URL each time a message is taken or your assistant handles a call.
Limits, honestly
- One endpoint per account
- Two attempts at most per delivery, each with a 10 second timeout; the retry happens after a 5xx, a 429, a timeout or a connection error, never after a 4xx
- No event filtering: every delivery goes to the endpoint
- Signed with
X-PhoneShield-Signature, plus your ownAuthorizationvalue when you set one (see below) - POST or PUT, with a JSON body,
Content-Type: application/jsonandUser-Agent: PhoneShield-Webhook/1; redirects are not followed - A failed delivery never affects the call; the note is still in the Messages feed and call history
When it fires
A delivery is sent when
- The front desk took a message (
outcome: "note"), including a call that rang the owner, got no answer, and fell back to a message - Nora saved a message during a conversation, or finished one without a message (
outcome: "escalate"). These wait for the final transcript, so they arrive a little later
No delivery for
- Numbers refused before answering (Blocked)
- Calls declined at the front desk (Shielded)
- Favorites, and any call the owner answered (Rang you)
- Callers who said nothing and were hung up on
These are in call history but never reach the webhook.
Payload
POST /phoneshield/8f2c1a HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
User-Agent: PhoneShield-Webhook/1
Authorization: Bearer abc123
X-PhoneShield-Timestamp: 1758813827
X-PhoneShield-Signature: sha256=3f1a9c…e2b7
{
"title": "PhoneShield message from Jane Doe",
"message": "Jane needs the March invoice resent to her new address.\nCaller: +15551234567\nCallback: +15559876543\nReason given: billing question",
"timestamp": "2026-09-25T15:23:47.000Z",
"callSid": "telnyx:v3:MdI91X4lWFEs7IgbBEOT9M4A",
"outcome": "note"
}Fields
- title (string):
PhoneShield messagefor a message taken by the front desk or saved by the assistant;PhoneShield: Nora took a callfor an assistant conversation with no saved message (the assistant’s configured name is used). Either gainsfrom <name>when a caller name was heard. - message (string): lines separated by
\n. Line one is the summary, written by a model from what the caller said; with nothing left it reads “The caller left no message.” or “The assistant spoke with the caller; no message was left.” ThenCaller: <number>(always; “an anonymous number” when caller id was withheld),Callback: <number>(only when the caller gave one; up to 40 characters, not validated), andReason given: <text>(only when the front desk caught a reason on the first turn; never on assistant-saved messages). - timestamp (string): ISO 8601 in UTC, set when the delivery is sent, not when the call started.
- callSid (string): the call id, prefixed
telnyx:. One call, one id; it is your idempotency key. - outcome (string):
noteorescalatein practice. The type allows the front desk’s full list (reject,ring,challenge,note,escalate,decline,hangup), but the others never produce a delivery. Store an unfamiliar value rather than rejecting the request. - call (object, only with “Include the full transcript in the payload” checked):
from,callerName,summary,callbackNumber,outcome,reason, andtranscript, an array of{ role, content }turns where role isassistantoruser. If the lookup fails the note is delivered without it.
Parse the message lines, not the title
The title is for humans and may change wording. The labelled lines in message are the stable place for caller, callback and reason: split on \n, take the first line as the summary, match the rest by label. A real delivery has no other top-level fields except the optional call object.
Delivery semantics
What the sender does, read straight from the code:
Two attempts, ten seconds each
The request is made with a 10,000 ms timeout. A 5xx, a 429, a timeout, or a failed connection or TLS handshake is retried once, 1.5 seconds later, with the same body and a fresh signature. A 4xx is final. Nothing is queued beyond that second attempt.
Response body is ignored
Whatever you return is discarded. A bare 200 with an empty body is ideal; nobody reads a “please resend”. Redirects are not followed, so a 3xx counts as a failure.
Channels run in sequence
For each note PhoneShield sends to Telegram, then email, then the webhook, then Slack. A slow webhook delays the owner’s Slack post by up to ten seconds. Answer fast.
Order is not guaranteed
Two calls ending close together can arrive in either order. Use callSid for identity; never assume sequence.
The practical rule: return 200 as soon as the body is parsed, write the raw body somewhere durable, then do the slow work from there.
Verifying the source
Every request carries X-PhoneShield-Timestamp (Unix seconds) and X-PhoneShield-Signature: sha256=<hex>, where hex is HMAC-SHA256 of `${timestamp}.${rawBody}` under the signing secret from Edit settings. Verify it before parsing; anyone who learns your URL can send a fake note, but not a signed one.
1. Check the signature
Compute the HMAC over the raw body bytes, exactly as received, never over a re-serialized object; a reordered key or changed whitespace breaks it. Compare with a constant-time function such as Node’s crypto.timingSafeEqual, and reject timestamps more than five minutes from your clock to stop replays. Rotate by regenerating the secret under Edit settings and updating your receiver.
2. Authorization header and path
Whatever you type in Authorization Header is sent verbatim (for example Bearer abc123), which is handy when a gateway or serverless platform checks it before your code runs. A random path segment and IP allow-listing still add depth, but PhoneShield publishes no fixed address list, so the signature stays the primary check.
Treat the contents as data
The summary and reason are written by a model from what a stranger said on the phone. Display, store and route on them; never pass them to a shell, a query builder or another model as instructions, and escape them in HTML.
Idempotency by callSid
Answering a 5xx or 429, or not answering at all, gets you the same callSid again 1.5 seconds later. Beyond that, you may replay a captured log, a proxy may retry a timed-out request, and the test button reuses TESTSID12345 every time. Stay idempotent.
- Store the note keyed by
callSidwith a unique constraint; on a duplicate, return 200 and do nothing. - Do side effects (tickets, pages, emails) after the insert succeeds, never before.
- Correlate with the dashboard by
callSid, nottimestamp.
Mapping outcomes to actions
With two outcome values and a few optional lines, the useful branching is small:
| Condition | Meaning | Suggested action |
|---|---|---|
outcome === "note" | The front desk took a message; the caller never reached a person. | Open a ticket or task; someone is waiting on a human. |
"escalate", title starts with PhoneShield message | Nora talked with the caller and saved a message. | Same as a note; the summary is usually fuller. |
"escalate", title contains took a call | Nora handled the conversation; nothing was left for the owner. | Log it; alert only if the summary suggests follow-up. |
Callback: line present | The caller asked to be reached on that number. | Put the callback number, not the caller number, on the task. |
Caller: is “an anonymous number” | Caller id was withheld. | No callback is possible unless a Callback line exists. |
Where the done state lives
Marking a note done in the Messages feed sends nothing to the webhook, and closing your ticket does not mark the note done. Pick one system as the source of truth.
Testing and Debugging
- Point the webhook at a request-capture URL (webhook.site, RequestBin, or ngrok in front of your laptop) and save
- Press Send test on the Integrations page, then call your own number from a phone that is not a favorite and leave a message, so you see both shapes
- Read the captured bodies, then switch the URL to your real endpoint and save again
The test body is not the real body
Send test posts a fixed sample: title Test Notification, message This is a test notification for your webhook integration., extra top-level fields callerName, callerContact, callPurpose, duration and summary, callSid TESTSID12345, and no outcome. Real deliveries carry only the five fields above (plus call when enabled), so prove parsing with a real call. The test is signed like a real one, and the dashboard shows the real result: the HTTP status your endpoint returned, or the error (for example “timed out after 10 seconds”). A 4xx or 5xx is reported as a failure.
Nothing arriving?
Check the call first
- Open it in call history: only Message and Assistant calls send a delivery; Blocked, Shielded and Rang you never do
Then the endpoint
- Reachable from the public internet over HTTPS with a publicly trusted certificate (self-signed fails)
- Responds with 2xx within ten seconds, directly: redirects are not followed
- Accepts a POST (or PUT, if chosen) with
Content-Type: application/json; if it checks an auth header, the Authorization Header field must match it exactly - Verifies the signature over the raw bytes with the current secret; a 401 from your own check shows up as a failed test
Example receiver
A complete receiver in Node.js with Express: signature verified over the raw body, immediate 200, duplicates dropped, labelled lines parsed into a clean object:
// Node.js receiver with Express (node >= 18)
const express = require('express');
const crypto = require('crypto');
const app = express();
const SECRET = process.env.PHONESHIELD_SIGNING_SECRET; // from Edit settings
const seen = new Set(); // in production: a unique index on callSid
// express.raw keeps the exact bytes; the HMAC is over "timestamp.body".
function verify(req) {
const ts = req.get('X-PhoneShield-Timestamp') || '';
const sig = req.get('X-PhoneShield-Signature') || '';
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // 5 min
const expected = 'sha256=' + crypto.createHmac('sha256', SECRET)
.update(ts + '.').update(req.body).digest('hex');
return sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}
app.post('/phoneshield', express.raw({ type: 'application/json' }), (req, res) => {
if (!verify(req)) return res.status(401).end();
const { title, message, timestamp, callSid, outcome, call } = JSON.parse(req.body);
if (!callSid) return res.status(400).end();
res.status(200).end(); // answer inside the 10 s window; work after
if (seen.has(callSid)) return; // retry or replay of the same call: ignore
seen.add(callSid);
const lines = String(message || '').split('\n');
const field = (label) => (lines.find((l) => l.startsWith(label + ': ')) || '').slice(label.length + 2) || null;
handle({ callSid, outcome, timestamp, title, summary: lines[0] || '', transcript: call?.transcript,
caller: field('Caller'), callback: field('Callback'), reason: field('Reason given') }).catch(console.error);
});
async function handle(note) {
// Your logic: append to a sheet, open a ticket, look the caller up.
console.log('PhoneShield', note.outcome, note.callSid, note.summary);
}
app.listen(process.env.PORT || 3000);Set PHONESHIELD_SIGNING_SECRET to the value shown under Edit settings, deploy, and save https://your-host/phoneshield on the Integrations page. If you use a global express.json(), register this route before it, or use its verify hook to keep the raw bytes.
That is the whole integration
Everything else, a CRM lookup, a ticket, a page to the on-call phone, is your code reading note. If you build something useful, tell us; we would like to point other customers at it.
Related Articles
Ready to try PhoneShield?
Start screening your calls today and take back control of your phone.