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/jsonDashboard session cookies and OAuth user tokens are not accepted. Speko returns 403 NOT_ENABLED_FOR_ORGANIZATION when the API has not been enabled for the API key'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
},
"agentId": null
}
]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.
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 message send. Extra send tags do not prevent a match. For example, the production subscription above matches { "environment": "production", "customer": "acme" }, but not { "environment": "staging" } or an untagged send. Because inbound messages do not originate from a tagged send request, they go only to catch-all subscriptions. Reactions to an outbound message use the tags from the original send.
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 → Webhooks → Logs 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.
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. |