Receive events with webhooks
Subscribe an HTTPS endpoint to Steer Phones call and voicemail events, verify the signature, and handle retries.
A webhook endpoint is an HTTPS URL you own that Steer Phones posts to when something happens on a call. It is the fastest way to make another system react in real time — pop a customer record when the phone rings, log an activity when the call ends, create a task when a voicemail lands — without polling for changes.
Webhooks work independently of the request-response API and need no API key: each delivery proves it came from Steer Phones by signing the request with a secret shared only with your endpoint.
Events you can subscribe to
An endpoint subscribes to one or more event types, and receives only those.
| Event type | Fires when | Typical use |
|---|---|---|
call.incoming |
A call arrives, before routing decides where it goes. | Screen pop |
call.ringing |
The call starts ringing a destination. | Live wallboard |
call.answered |
Someone answers. | Start a timer, open a record |
call.on_hold |
The call is placed on hold. | Hold-time monitoring |
call.ended |
The call finishes, with its outcome and duration. | Activity logging |
voicemail.created |
A voicemail is recorded. | Task or ticket creation |
call.analysis.completed |
AI analysis of a call finishes. | Scoring and coaching workflows |
Call lifecycle events are delivered ahead of other events when both are queued, because a screen pop is worthless if it arrives after the call is over.
What a delivery looks like
Steer Phones sends an HTTP POST with a JSON body in a consistent envelope. The event-specific content is
always under data; the envelope around it never changes shape.
{
"id": "6f1c0f2e-6a1f-4f0f-9a10-2b0f4c9a71d3",
"type": "call.incoming",
"timestamp": "2026-08-03T15:04:05.000Z",
"phoneSystem": "b3f1a5c2-1d44-4c19-9a1e-2f7d8c4b0e11",
"data": {
"callSid": "CAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"direction": "inbound",
"from": "+15555550123",
"to": "+15555550100",
"startTime": "2026-08-03T15:04:04.000Z",
"extension": "0f6a1c2e-71d3-4b19-8a10-2b0f4c9a5f22"
}
}
ididentifies this delivery. It stays the same across retries, so use it to make your handler idempotent. The same underlying event sent to two endpoints has a differentidfor each.typeis the event type from the table above.timestampis when the event occurred, in UTC.phoneSystemis your phone system’s identifier.datacarries the event’s fields. Call events include the call identifier, direction, the numbers involved, and — where known — the extension or call group and the duration or outcome. Voicemail events include the voicemail identifier, the caller, and the recording length.
Fields can be added to data over time, so ignore members you do not recognize rather than
rejecting the delivery.
Each request carries these headers:
| Header | Value |
|---|---|
Content-Type |
application/json |
X-Steer-Phones-Signature |
Hex-encoded HMAC-SHA256 signature of the request. |
X-Steer-Phones-Timestamp |
Unix time, in seconds, when this attempt was signed. |
User-Agent |
SteerPhones-Webhook/1.0 |
Verify every delivery
Your endpoint is a public URL, so anyone can post to it. Verify the signature before you act on a payload, and reject anything that fails.
- Read the raw request body as bytes, exactly as received. Do not parse and re-serialize it first — re-serializing changes whitespace and key order, and the signature will not match.
- Read
X-Steer-Phones-Timestampand build the string<timestamp>.<raw body>. - Compute HMAC-SHA256 over that string using your endpoint’s signing secret, hex-encoded.
- Compare it to
X-Steer-Phones-Signaturewith a constant-time comparison. - Reject deliveries whose timestamp is far outside your tolerance — a few minutes is a reasonable window — so a captured request cannot be replayed later.
const { createHmac, timingSafeEqual } = require('node:crypto');
function isValidSteerWebhook(rawBody, headers, secret) {
const timestamp = headers['x-steer-phones-timestamp'];
const signature = headers['x-steer-phones-signature'];
if (!timestamp || !signature) return false;
const ageSeconds = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!Number.isFinite(ageSeconds) || ageSeconds > 300) return false;
const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
if (expected.length !== signature.length) return false;
const expectedBuf = Buffer.from(expected, 'hex');
const signatureBuf = Buffer.from(signature, 'hex');
if (expectedBuf.length !== signatureBuf.length) return false;
return timingSafeEqual(expectedBuf, signatureBuf);
}
Each retry is signed fresh, so the timestamp and signature differ between attempts of the same
delivery while the body — and therefore the envelope id — stays the same. Deduplicate on id, not
on the signature.
Store the signing secret the way you store any credential: in a secret manager, never in source control or a browser. If it is exposed, ask Steer Phones to regenerate it; the previous secret stops working immediately, so deploy the new value promptly.
Delivery, retries, and auto-disable
- Respond fast. Return a 2xx status within 15 seconds. Anything slower is recorded as a timeout. Acknowledge first and do your real work asynchronously.
- Any 2xx counts as success. The response body is ignored; only the status matters.
- Failures are retried. A delivery is attempted up to five times with exponentially increasing delays, spanning roughly fifteen minutes. After the last attempt it is marked failed and not retried again.
- Retries can arrive out of order. A retried event may land after a later event. Use the envelope
timestampto order what you store, not arrival order. - Redirects are not followed. A 301 or 302 counts as a failure. Register the final URL.
- Repeated failures disable the endpoint. Consecutive failures across deliveries are counted, and once they reach the endpoint’s threshold — ten by default — the endpoint is automatically disabled and stops receiving events until it is re-enabled. A single success resets the counter, so an endpoint that is merely flaky is not disabled.
An endpoint disabled this way is a silent outage in your integration if nobody is watching for it. Alert on “no Steer Phones events received recently” on your side rather than relying on noticing the gap.
Endpoint requirements
- HTTPS only. Plain HTTP is rejected.
- A publicly resolvable host. Localhost and private or reserved addresses are rejected, so an endpoint behind a corporate firewall needs a public tunnel or gateway.
- Up to 25 endpoints per phone system, each with its own subscription list and its own secret.
- A stable URL. Changing the URL is an update to the endpoint, not a redirect from the old one.
Separate endpoints are worth using when different systems want different events — a CRM subscribing to call lifecycle events and a ticketing system subscribing only to voicemail, each with its own secret, so revoking one does not disturb the other.
Ask Steer Phones to register an endpoint
Endpoint registration is handled by Steer Phones today; there is no webhook page in the dashboard. Send your Steer Phones contact:
- The phone system the endpoint is for.
- The HTTPS URL to deliver to.
- The event types to subscribe to.
- A short description, so the endpoint is recognizable later.
- Whether you want expanded entity data (below).
Steer Phones registers the endpoint, returns the signing secret once, and can send a test event to it — a
test.ping delivery signed exactly like a real one, so you can confirm your verification code works
before any live call depends on it. Steer Phones can also enable, disable, or delete an endpoint, regenerate
its secret, and review the delivery history for it — including the response status and timing of each
attempt — when you need to know whether a specific event reached you.
Expanded entity data
By default, references inside data are identifiers: extension and callGroup are UUID strings.
An endpoint can instead be set to include expanded data, replacing each identifier with a small
object — an extension’s number, name, and status; a call group’s name, number, ring pattern, and
member extensions; the phone system’s name and time zone.
Turn it on when the receiving system has no other way to resolve those identifiers and would otherwise have to call back for every event. Leave it off when your integration already has that information, since it makes every payload larger.
Troubleshooting
- Signature never matches: You are almost certainly verifying a re-serialized body. Capture the raw bytes before any JSON middleware parses them.
- Nothing arrives at all: Confirm the endpoint is enabled and not auto-disabled after a run of failures, that the URL is publicly reachable over HTTPS, and that the endpoint subscribes to the event type you expect.
- Events arrive but one type is missing: The subscription list does not include it. Subscriptions are per endpoint.
- Duplicate events: Retries and multiple endpoints both produce more than one request. Deduplicate
on the envelope
id. - Deliveries stop after an incident on your side: The endpoint was likely auto-disabled. Ask Steer Phones to re-enable it once your endpoint is healthy; the failure counter is reset when it is.
- Events fire during test calls only: Check that the phone system in
phoneSystemis the one you intend to integrate with — each system’s events go to its own endpoints.