iMessage phone numbers
Private API for listing dedicated iMessage numbers, placing calls, sending iMessage-first messages, and receiving events.
The iMessage phone-number API is a private, API-only surface enabled for organizations that have dedicated iMessage numbers assigned to them by Speko, plus the internal Speko admin organization. It is separate from the standard /v1/phone-numbers inventory.
Use a Speko bearer API key on every request:
Authorization: Bearer sk_live_...
Content-Type: application/jsonPlacing a call and sending a message require a bearer API key; a dashboard session cookie is rejected with 403 API_KEY_REQUIRED. The read and configuration routes — listing numbers, number tags, and webhook subscriptions — also answer a dashboard session, because Settings → iMessage drives them. Speko returns 403 NOT_ENABLED_FOR_ORGANIZATION when the API has not been enabled for the caller's organization.
List numbers
GET /v1/imessage-phone-numbersThe response contains your organization's pool: the dedicated iMessage numbers Speko has assigned to it. Use id, not the raw phone number, in call and message requests.
[
{
"id": "2b3fb8b3-17ef-4e78-b742-69a4ef55c223",
"phoneNumber": "+19295550101",
"status": "active",
"smsStatus": "ready",
"smsReadyAt": null,
"capabilities": {
"calls": true,
"messages": true,
"imessageFirst": true,
"smsFallback": true
},
"tags": {
"environment": "production"
},
"agentId": null
}
]tags are the number's routing tags — see Tag a number. agentId names the Speko agent currently answering on the number when a workspace member has selected it on that agent's iMessage page; null means the number is available in the pool (turning an agent's iMessage off releases its number back to the pool). Numbers outside your pool are invisible and return 404 PHONE_NUMBER_NOT_FOUND from the call and message endpoints.
Transitional behavior: an organization that was granted API access before pools existed and has no pooled numbers yet sees the historical whole-inventory response (without agentId) and may use any unpooled number. Both end the moment its first number is assigned to a pool.
Speko prepares active dedicated numbers for API use behind the scenes, including creating and attaching the provider identity required for sending. Provider identity details are intentionally not exposed. Paused or otherwise unavailable numbers remain visible, but their capability flags are false and they cannot be used by the call or message endpoints.
Tag a number
PUT /v1/imessage-phone-numbers/{phoneNumberId}/tags{
"tags": {
"environment": "production",
"team": "dispatch"
}
}Number tags use the same key/value shape and limits as webhook subscription tags: at most 20 pairs, keys at most 64 characters, values at most 256. The request replaces the number's tags wholesale — send {"tags": {}} to clear them.
Unlike the per-send tags field, which describes one outbound message, these apply to every event on the line. That makes them the only way to route inbound traffic: an inbound message did not come from a Speko send request, so there is nothing else to match a subscription against. A subscription tagged the same as a number therefore receives that number's inbound and outbound events.
{
"phoneNumberId": "2b3fb8b3-17ef-4e78-b742-69a4ef55c223",
"phoneNumber": "+19295550101",
"tags": {
"environment": "production",
"team": "dispatch"
},
"updatedAt": "2026-07-28T12:00:00.000Z"
}Number tags are part of the shared webhook fan-out, so they are managed by the same organization that manages the subscriptions: other organizations receive 403 NOT_ENABLED_FOR_ORGANIZATION. A number outside the caller's reach returns 404 PHONE_NUMBER_NOT_FOUND.
Place a call
POST /v1/imessage-phone-numbers/{phoneNumberId}/calls{
"to": "+12015551234",
"agentId": "agent_dispatch",
"variables": {
"load_id": "42"
},
"metadata": {
"externalJobId": "job_123"
},
"maxDurationSeconds": 900
}to must be E.164. agentId selects the Speko voice agent that runs the call. variables, metadata, and maxDurationSeconds are optional; the duration must be between 30 and 14,400 seconds.
Speko originates the PSTN leg through Telnyx Call Control and supplies the selected iMessage number as caller ID. After the recipient answers, Telnyx transfers the live call to the Speko agent over the configured SIP bridge. The customer never submits a raw from value.
{
"callId": "0ab83b2f-047e-4b43-a350-8ab948eb84d8",
"callControlId": "v3:abc123...",
"status": "dialing",
"to": "+12015551234",
"from": "+19295550101",
"phoneNumberId": "2b3fb8b3-17ef-4e78-b742-69a4ef55c223"
}callId is the normal Speko call/session ID. Use it with /v1/calls/{callId} and the standard call report, event, recording, and transfer APIs.
Send a message
DEPRECATED This endpoint is deprecated in favor of the agent-scoped POST /v1/imessage/{agentId}/messages and will be removed on August 14, 2026.
POST /v1/imessage-phone-numbers/{phoneNumberId}/messages{
"to": "+12015551234",
"text": "Load 42 is ready for pickup.",
"tags": {
"environment": "production",
"customer": "acme"
}
}tags is an optional string-to-string object used to route the message's lifecycle webhooks. A request can include at most 20 tags; keys are at most 64 characters and values are at most 256 characters. Matching is case-sensitive.
Speko asks Inkbox to deliver from the selected dedicated identity. Inkbox attempts iMessage first. When the destination is not associated with an Apple ID, the dedicated-line delivery policy may downgrade the message to SMS asynchronously. When Inkbox resolves that downgrade immediately, the response looks like this:
{
"messageId": "76528fd0-9aeb-43d0-a851-b813fdfaeae0",
"conversationId": "37f30c7f-565b-45fa-b04c-df29b5af2068",
"phoneNumberId": "2b3fb8b3-17ef-4e78-b742-69a4ef55c223",
"to": "+12015551234",
"attemptedChannel": "imessage",
"channel": "sms",
"fallbackApplied": true,
"deliveryFinal": false,
"status": "queued",
"tags": {
"environment": "production",
"customer": "acme"
}
}Inkbox accepts delivery asynchronously. The immediate response is a queued snapshot, not a delivery receipt. While an iMessage attempt is still registered, pending, queued, or accepted, channel and fallbackApplied are null because fallback has not been resolved yet:
{
"messageId": "a6190882-9342-42b6-bf26-85bb6a4f6539",
"conversationId": "b1166c65-7b35-4d5b-b954-684d754fec8f",
"phoneNumberId": "d70ffac3-3ebd-4c04-94e0-72fc4a090643",
"to": "+998905748252",
"attemptedChannel": "imessage",
"channel": null,
"fallbackApplied": null,
"deliveryFinal": false,
"status": "pending",
"tags": {}
}channel becomes imessage, sms, or rcs once Inkbox confirms the transport or reports a downgrade. deliveryFinal becomes true on delivered, declined, or error. Track the authoritative result through the registered imessage.sent, imessage.delivered, and imessage.delivery_failed webhooks.
Do not send a second SMS when the request succeeds: retrying a pending asynchronous delivery through another endpoint can create a duplicate. Retry only failed HTTP requests according to their status and your own idempotency policy.
Receive webhook events
Register each subscriber's HTTPS endpoint at runtime using the same Speko bearer API key as the other phone-number endpoints. This production subscription receives events only for sends tagged with environment=production:
PUT /v1/imessage-phone-numbers/webhook
Content-Type: application/json
{
"url": "https://prod.example.com/webhooks/speko-imessage",
"tags": {
"environment": "production"
}
}By default, the subscription uses the registering organization's global Webhook signing secret from the dashboard's API Keys page:
{
"configured": true,
"id": "3a8f1976-4c76-44c6-97d5-303f8ed0ce81",
"url": "https://prod.example.com/webhooks/speko-imessage",
"tags": {
"environment": "production"
},
"signingSecretSource": "organization",
"eventTypes": [
"imessage.received",
"imessage.reaction_received",
"imessage.sent",
"imessage.delivered",
"imessage.delivery_failed"
],
"updatedAt": "2026-07-16T12:00:00.000Z"
}Speko does not mint or return a new secret for each subscription. The organization secret is resolved when each webhook is delivered, so rotating it from the dashboard updates every subscription whose signingSecretSource is organization.
To use a different secret for one endpoint, provide an explicit signingSecret when registering it:
{
"url": "https://partner.example.com/webhooks/speko-imessage",
"tags": { "partner": "carrier-a" },
"signingSecret": "customer-selected-secret"
}The override is encrypted at rest, is never returned by the API, and is reported as "signingSecretSource": "custom". A different URL creates another subscription without affecting existing ones. Calling PUT again with the same URL updates that subscription; omitting signingSecret switches it back to the organization secret.
Register a catch-all endpoint by omitting tags or passing {}. It receives every event, including inbound messages and events for sends that did not include tags:
{
"url": "https://audit.example.com/webhooks/speko-imessage"
}A tagged subscription matches when every key/value pair configured on the subscription is present on the event. Extra event tags do not prevent a match. For example, the production subscription above matches { "environment": "production", "customer": "acme" }, but not { "environment": "staging" } or an untagged event.
An event's tags come from two places, merged per event:
- The number's tags (see Tag a number), which apply to every event on that line, in both directions.
- The send request's
tags, which apply to that message's lifecycle events only. On a shared key the send's value wins, being the more specific statement.
So an inbound message carries exactly its receiving number's tags — that is what lets a tagged subscription receive inbound traffic at all. An untagged number still reaches only catch-all subscriptions. Reactions to an outbound message use the tags from the original send, merged over the number's.
List subscriptions without exposing their secrets:
GET /v1/imessage-phone-numbers/webhook{
"configured": true,
"subscriptions": [
{
"id": "3a8f1976-4c76-44c6-97d5-303f8ed0ce81",
"url": "https://prod.example.com/webhooks/speko-imessage",
"tags": { "environment": "production" },
"signingSecretSource": "organization",
"eventTypes": [
"imessage.received",
"imessage.reaction_received",
"imessage.sent",
"imessage.delivered",
"imessage.delivery_failed"
],
"updatedAt": "2026-07-16T12:00:00.000Z"
}
]
}Remove one subscription by ID:
DELETE /v1/imessage-phone-numbers/webhook/3a8f1976-4c76-44c6-97d5-303f8ed0ce81Remove all subscriptions and stop forwarding events:
DELETE /v1/imessage-phone-numbers/webhookSubscriptions are shared by the customer and Speko admin organizations because they manage the same private phone-number integration. Dashboard sessions cannot manage them; API-key authentication is required.
Every subscriber delivery is visible in Settings → iMessage → Webhooks, where each subscription has its own log, and in Settings → Webhooks → Logs alongside workspace endpoints, for the organization that registered that endpoint. Logs include the stable Inkbox event ID, routing tags, sanitized request headers and body, bounded response body, HTTP result, duration, and every provider-triggered attempt. They are retained for 30 days even if the API subscription is later deleted. Inkbox controls retries for these events, so manual redelivery is not available from the dashboard or delivery API.
Speko registers a separate Inkbox ingress subscription for every active number returned by the list endpoint, receives all iMessage event types, and then applies the subscriber tag predicates described above:
| Event | Meaning |
|---|---|
imessage.received | A message arrived from the remote number. |
imessage.reaction_received | A reaction or tapback arrived. |
imessage.sent | Inkbox accepted an outbound message. |
imessage.delivered | The carrier or iMessage service reported delivery. |
imessage.delivery_failed | Delivery failed; the message contains provider error fields. |
The POST body is the original Inkbox JSON event, unchanged. Routing tags are used by Speko to choose endpoints; they are not injected into the provider event body. For example:
{
"id": "evt_01JZ...",
"event_type": "imessage.delivered",
"timestamp": "2026-07-16T09:30:00.000Z",
"data": {
"message": {
"id": "76528fd0-9aeb-43d0-a851-b813fdfaeae0",
"conversation_id": "37f30c7f-565b-45fa-b04c-df29b5af2068",
"remote_number": "+12015551234",
"service": "imessage",
"status": "delivered"
},
"reaction": null,
"contacts": [],
"agent_identities": []
}
}Speko adds these headers:
| Header | Value |
|---|---|
webhook-id | The stable Inkbox event ID. Deduplicate deliveries on this value. |
webhook-timestamp | Unix timestamp used by the signature. |
webhook-signature | Standard Webhooks HMAC-SHA256 signature (v1,...). |
x-speko-phone-number-id | The same stable number ID returned by GET /v1/imessage-phone-numbers. |
x-speko-phone-number | The managed E.164 number. |
x-speko-inkbox-identity-id | The internal Inkbox identity ID backing the managed number. |
x-speko-event-type | The event type from the body. |
Verify webhook-signature against the raw request body using the organization's dashboard webhook secret, or the custom secret supplied for that subscription. Do not parse and re-serialize JSON before verification. Also reject stale webhook-timestamp values; a five-minute tolerance is recommended.
Each request carries more than one v1, token. Speko signs under two key derivations — the secret's raw bytes, and the Standard Webhooks derivation of stripping whsec_ and base64-decoding the remainder — so either style of verifier matches. Accept the request if any token matches, as the spec requires; never assume there is exactly one.
If your signing secret contains - or _, it predates 2026-07-28 and was minted
with the base64url alphabet. Strict base64 decoders reject it outright — the
standardwebhooks package throws Base64Coder: incorrect characters for decoding
in its constructor. Either rotate the secret to get a current-format one, or build
the verifier with new Webhook(secret, { format: 'raw' }). The same applies to a
custom signingSecret that is not whsec_ + standard base64.
Return any 2xx response after durably accepting an event. A timeout or non-2xx response makes the pass-through delivery fail, and Inkbox may retry the same event. Retries retain the same webhook-id, so handlers must be idempotent.
Errors
| Status | Code | Meaning |
|---|---|---|
400 | validation error | The ID, E.164 number, body, or duration is invalid. |
402 | INSUFFICIENT_CREDITS | The organization has no positive Speko credit balance for a call. |
403 | API_KEY_REQUIRED | Authentication was not a Speko bearer API key. |
403 | NOT_ENABLED_FOR_ORGANIZATION | The private API is not enabled for this organization. |
404 | PHONE_NUMBER_NOT_FOUND | The number ID is unknown, inactive, released, or no longer iMessage-enabled. |
4xx/502 | MESSAGE_SEND_FAILED | Inkbox rejected the message or its service was unavailable. |
502 | TELNYX_DIAL_FAILED | Telnyx rejected the outbound call or could not originate it. |