Skip to main content
This is the beta webhook API, available in open beta. For the existing webhook system, see Legacy webhooks.
Every webhook delivery uses the common envelope with the same id, apiVersion, createdAt, type, and data fields. The sections below document only the per-event data wrapper - the part that varies by event type. The type field on the envelope determines which schema below applies.

Quick index

Event typeWhen it fires
message.receivedAn inbound SMS, MMS, or message was received by a Quo number.
message.deliveredAn outbound message was delivered. Not a read receipt.
message.failedAn outbound message failed to deliver. Includes a carrier or provider error code when available.
call.ringingA call started ringing. Fires for incoming and outgoing. Context is narrower than other call events.
call.answeredA call was connected. answeredByUserId identifies the Quo-side user associated with the answer when known. Outgoing calls fire this event for voicemail pickup too.
call.completedA call ended. Terminal lifecycle event with the final status and duration.
call.forwardedAn incoming call was forwarded. Includes the forwarding phone numbers.
call.missedAn incoming call ended without being answered. Outgoing calls do not fire this event.
call.recording.completedA call recording finished processing. May arrive after call.completed.
call.summary.completedA call summary finished generating. May arrive long after the call ends.
call.transcript.completedA call transcript finished processing. Order vs. summary is not guaranteed.
call.voicemail.completedA voicemail was left and finished processing. Use resource.callId to correlate with the source call lifecycle.
contact.updatedA contact was created or its fields changed.
contact.deletedA contact was deleted. Resource shape matches contact.updated.
To see any event payload against your endpoint without waiting for live traffic, call POST /webhooks/:id/events/test with the eventType you want to inspect.

Field semantics

Two patterns appear in multiple events. Both indicate why a field may be empty rather than implying “no data exists”.

contacts.lookupStatus

Explains why contacts.ids may be empty:
StateMeaning
matchedQuo found one or more matching contacts. ids is populated.
noneQuo checked for matching contacts and found none. ids is [].
unavailableQuo could not determine contact matches because the event lacked sufficient context. Treat ids as unknown, not empty.

participants.resolution

Explains whether participant context was resolved on call events:
StateMeaning
availableWorkspace and external participants are populated correctly.
unavailableParticipant context could not be resolved. Treat empty arrays as unknown rather than as no participants.

Common type aliases

Several events reuse these types. Each event references them by name rather than redefining them inline.
type MessageStatus =
  | 'queued' | 'sending' | 'sent' | 'delivered' | 'undelivered' | 'failed'
  | 'receiving' | 'received' | 'accepted' | 'scheduled' | 'read'
  | 'partially_delivered' | 'canceled'

interface MessageContext {
  phoneNumberId: string | null
  conversationId: string | null
  userId: string
  contacts: { ids: string[]; lookupStatus: 'matched' | 'none' | 'unavailable' }
  senderIdentifier: string
  recipientIdentifiers: string[]
}

interface CallContext {
  phoneNumberId: string | null
  conversationId: string | null
  phoneNumberType: 'shared' | 'private' | 'external' | null
  userId: string
  contacts: { ids: string[]; lookupStatus: 'matched' | 'none' | 'unavailable' }
  participants: {
    workspace: string[]
    external: string[]
    resolution: 'available' | 'unavailable'
  }
}

interface CallRingingContext {
  phoneNumberId: string | null
  conversationId: string | null
  userId: string
}

interface ContactContext {
  userId: string
  sharedWithIds: string[]
}

message.received

An inbound message was received by Quo. Use this as your inbound trigger.
interface MessageReceivedEvent {
  type: 'message.received'
  data: {
    resource: {
      id: string
      direction: 'incoming'
      text: string
      media: Array<{ type?: string | null; url: string }>
      status: MessageStatus
      createdAt: string
    }
    context: MessageContext
    links: { quo: string | null }
  }
}
{
  "data": {
    "resource": {
      "id": "AC-message",
      "direction": "incoming",
      "text": "hello",
      "media": [],
      "status": "received",
      "createdAt": "2026-04-13T12:00:00.000Z"
    },
    "context": {
      "phoneNumberId": "PN123",
      "conversationId": "CN123",
      "userId": "US123",
      "contacts": { "ids": ["CT123"], "lookupStatus": "matched" },
      "senderIdentifier": "+15550001111",
      "recipientIdentifiers": ["+15550002222"]
    },
    "links": { "quo": "https://my.quo.com/inbox/..." }
  }
}
senderIdentifier and recipientIdentifiers are raw participant identifiers. They are usually E.164 phone numbers, but direct-number and internal flows can emit non-phone identifiers.

message.delivered

An outbound message was delivered. This is delivery confirmation, not a read receipt.
interface MessageDeliveredEvent {
  type: 'message.delivered'
  data: {
    resource: {
      id: string
      direction: 'outgoing'
      text: string
      media: Array<{ type?: string | null; url: string }>
      status: MessageStatus
      createdAt: string
    }
    context: MessageContext
    links: { quo: string | null }
  }
}
{
  "data": {
    "resource": {
      "id": "AC-message",
      "direction": "outgoing",
      "text": "hello",
      "media": [],
      "status": "delivered",
      "createdAt": "2026-04-13T12:00:00.000Z"
    },
    "context": {
      "phoneNumberId": "PN123",
      "conversationId": "CN123",
      "userId": "US123",
      "contacts": { "ids": ["CT123"], "lookupStatus": "matched" },
      "senderIdentifier": "+15550002222",
      "recipientIdentifiers": ["+15550001111"]
    },
    "links": { "quo": "https://my.quo.com/inbox/..." }
  }
}
If you correlate deliveries with conversations, key on context.conversationId and context.phoneNumberId rather than links.quo.

message.failed

An outbound message failed to deliver. resource.errorCode contains the carrier or provider failure code when one is available; it is null when the failure reason is unknown.
interface MessageFailedEvent {
  type: 'message.failed'
  data: {
    resource: {
      id: string
      direction: 'outgoing'
      text: string
      status: MessageStatus
      errorCode: string | null
      createdAt: string
    }
    context: MessageContext
    links: { quo: string | null }
  }
}
{
  "data": {
    "resource": {
      "id": "AC-message",
      "direction": "outgoing",
      "text": "hello",
      "status": "failed",
      "errorCode": "30006",
      "createdAt": "2026-04-13T12:00:00.000Z"
    },
    "context": {
      "phoneNumberId": "PN123",
      "conversationId": "CN123",
      "userId": "US123",
      "contacts": { "ids": ["CT123"], "lookupStatus": "matched" },
      "senderIdentifier": "+15550002222",
      "recipientIdentifiers": ["+15550001111"]
    },
    "links": { "quo": "https://my.quo.com/inbox/..." }
  }
}
errorCode is the raw code returned by the carrier or messaging provider. It is present only on message.failed and is absent from all other message events.

call.ringing

call.ringing fires once at the start of an incoming or outgoing call. It is intentionally lightweight because call context is not fully available yet.
interface CallRingingEvent {
  type: 'call.ringing'
  data: {
    resource: {
      id: string
      direction: 'incoming' | 'outgoing'
      createdAt: string
      updatedAt: string
    }
    context: CallRingingContext
    links: { quo: string | null }
  }
}
{
  "data": {
    "resource": {
      "id": "AC-call",
      "direction": "incoming",
      "createdAt": "2026-04-13T12:00:00.000Z",
      "updatedAt": "2026-04-13T12:00:00.000Z"
    },
    "context": {
      "phoneNumberId": "PN123",
      "conversationId": "CN123",
      "userId": "US123"
    },
    "links": { "quo": "https://my.quo.com/inbox/..." }
  }
}
Later call events include richer context such as contacts, participants, and phoneNumberType.

call.answered

A call was connected. answeredByUserId is the Quo user associated with the answer when known. It is not the external party who answered an outgoing call.
interface CallAnsweredEvent {
  type: 'call.answered'
  data: {
    resource: {
      id: string
      direction: 'incoming' | 'outgoing'
      createdAt: string
      answeredAt: string | null
      answeredByUserId: string | null
      updatedAt: string | null
    }
    context: CallContext
    links: { quo: string | null }
  }
}
{
  "data": {
    "resource": {
      "id": "AC-call",
      "direction": "incoming",
      "createdAt": "2026-04-13T11:59:55.000Z",
      "answeredAt": "2026-04-13T12:00:00.000Z",
      "answeredByUserId": "US123",
      "updatedAt": "2026-04-13T12:00:00.000Z"
    },
    "context": {
      "phoneNumberId": "PN123",
      "conversationId": "CN123",
      "phoneNumberType": "shared",
      "userId": "US123",
      "contacts": { "ids": ["CT123"], "lookupStatus": "matched" },
      "participants": {
        "workspace": ["+15550000001"],
        "external": ["+15550000002"],
        "resolution": "available"
      }
    },
    "links": { "quo": "https://my.quo.com/inbox/..." }
  }
}
call.answered also fires on outgoing calls when the recipient’s voicemail picks up. Treat this event as “the call connected” rather than “a person is on the line.”

call.completed

A call ended. This is the terminal lifecycle event with the final status, duration, and timestamps. Recording, summary, transcript, and voicemail artifacts (if any) arrive as separate events and may be delayed by minutes.
interface CallCompletedEvent {
  type: 'call.completed'
  data: {
    resource: {
      id: string
      direction: 'incoming' | 'outgoing'
      status: CallCompletedStatus
      createdAt: string
      answeredAt: string | null
      completedAt: string | null
      updatedAt: string | null
      duration: number | null
      hasVoicemail: boolean
    }
    context: CallContext
    links: { quo: string | null }
  }
}

type CallCompletedStatus =
  | 'answered' | 'unanswered' | 'failed' | 'forwarded'
  | 'abandoned' | 'ai-handled' | 'unknown'
{
  "data": {
    "resource": {
      "id": "AC-call",
      "direction": "incoming",
      "status": "answered",
      "createdAt": "2026-04-13T11:59:55.000Z",
      "answeredAt": "2026-04-13T12:00:00.000Z",
      "completedAt": "2026-04-13T12:00:55.000Z",
      "updatedAt": "2026-04-13T12:00:55.000Z",
      "duration": 55,
      "hasVoicemail": false
    },
    "context": {
      "phoneNumberId": "PN123",
      "conversationId": "CN123",
      "phoneNumberType": "shared",
      "userId": "US123",
      "contacts": { "ids": ["CT123"], "lookupStatus": "matched" },
      "participants": {
        "workspace": ["+15550000001"],
        "external": ["+15550000002"],
        "resolution": "available"
      }
    },
    "links": { "quo": "https://my.quo.com/inbox/..." }
  }
}
Possible values for status:
ValueMeaning
answeredThe call connected. For outgoing calls, this may be either a person answering or the recipient’s voicemail picking up.
unansweredThe call ended without being answered. May still have a voicemail — check hasVoicemail.
failedThe call could not be placed or connected.
forwardedThe call was forwarded to another number.
abandonedThe caller hung up before the call was answered.
ai-handledAn AI agent handled the call.
unknownQuo could not classify the final state.
  • hasVoicemail indicates whether a voicemail was left. The voicemail itself arrives as a separate call.voicemail.completed event.
  • duration is in seconds and may be null for calls that never connected.

call.forwarded

An incoming call was forwarded. This event carries the phone numbers involved in the forward. Other call lifecycle events do not include forwardedFrom or forwardedTo.
interface CallForwardedEvent {
  type: 'call.forwarded'
  data: {
    resource: {
      id: string
      createdAt: string
      updatedAt: string | null
      forwardedFrom: string
      forwardedTo: string
    }
    context: CallContext
    links: { quo: string | null }
  }
}
{
  "data": {
    "resource": {
      "id": "AC-call",
      "createdAt": "2026-04-13T11:59:55.000Z",
      "updatedAt": "2026-04-13T12:00:00.000Z",
      "forwardedFrom": "+15550000001",
      "forwardedTo": "+15550000003"
    },
    "context": {
      "phoneNumberId": "PN123",
      "conversationId": "CN123",
      "phoneNumberType": "shared",
      "userId": "US123",
      "contacts": { "ids": ["CT123"], "lookupStatus": "matched" },
      "participants": {
        "workspace": ["+15550000001"],
        "external": ["+15550000002"],
        "resolution": "available"
      }
    },
    "links": { "quo": "https://my.quo.com/inbox/..." }
  }
}

call.missed

An incoming call ended without being answered. Outgoing calls do not produce this event — they terminate with call.completed and a non-answered status. The payload is intentionally minimal. To get the full call shape, look up the call by resource.id in the API.
interface CallMissedEvent {
  type: 'call.missed'
  data: {
    resource: {
      id: string
      createdAt: string
      updatedAt: string
    }
    context: CallContext
    links: { quo: string | null }
  }
}
{
  "data": {
    "resource": {
      "id": "AC-call",
      "createdAt": "2026-04-13T11:59:55.000Z",
      "updatedAt": "2026-04-13T12:00:30.000Z"
    },
    "context": {
      "phoneNumberId": "PN123",
      "conversationId": "CN123",
      "phoneNumberType": "shared",
      "userId": "US123",
      "contacts": { "ids": ["CT123"], "lookupStatus": "matched" },
      "participants": {
        "workspace": ["+15550000001"],
        "external": ["+15550000002"],
        "resolution": "available"
      }
    },
    "links": { "quo": "https://my.quo.com/inbox/..." }
  }
}

call.recording.completed

A call recording finished processing. May arrive after call.completed. recordings is always an array — an empty [] means no recording metadata is available in this payload.
interface CallRecordingCompletedEvent {
  type: 'call.recording.completed'
  data: {
    resource: {
      id: string
      direction: 'incoming' | 'outgoing'
      createdAt: string
      answeredAt: string | null
      completedAt: string | null
      updatedAt: string | null
      duration: number | null
      recordings: CallRecording[]
    }
    context: CallContext
    links: { quo: string | null }
  }
}

interface CallRecording {
  id: string | null
  duration: number | null
  startTime: string | null
  type: string | null
  url: string | null
}
{
  "data": {
    "resource": {
      "id": "AC-call",
      "direction": "incoming",
      "createdAt": "2026-04-13T11:59:55.000Z",
      "answeredAt": "2026-04-13T12:00:00.000Z",
      "completedAt": "2026-04-13T12:00:55.000Z",
      "updatedAt": "2026-04-13T12:01:05.000Z",
      "duration": 55,
      "recordings": [
        {
          "id": "REabc123",
          "duration": 55,
          "startTime": "2026-04-13T12:00:00.000Z",
          "type": "audio/mpeg",
          "url": "https://recordings.example.com/REabc123.mp3"
        }
      ]
    },
    "context": {
      "phoneNumberId": "PN123",
      "conversationId": "CN123",
      "phoneNumberType": "shared",
      "userId": "US123",
      "contacts": { "ids": ["CT123"], "lookupStatus": "matched" },
      "participants": {
        "workspace": ["+15550000001"],
        "external": ["+15550000002"],
        "resolution": "available"
      }
    },
    "links": { "quo": "https://my.quo.com/inbox/..." }
  }
}
Download or persist the recording file rather than relying on the URL for long-term access.

call.summary.completed

A call summary finished processing. This is a summary readiness event, not a call-ended event — the call may have ended much earlier. Trust processingStatus, not arrival time, when interpreting summary state.
interface CallSummaryCompletedEvent {
  type: 'call.summary.completed'
  data: {
    resource: {
      callId: string
      processingStatus: 'absent' | 'in-progress' | 'completed' | 'failed'
      summary: string[] | null
      nextSteps: string[] | null
      fromPhoneNumber: string | null
      handledByAiAgent: boolean
      answeredByUserId: string | null
      jobs: AgentCallSummaryJob[]
    }
    context: CallContext
    links: { quo: string | null }
  }
}

interface AgentCallSummaryJob {
  icon: string
  name: string
  result: { data: Array<{ name: string; value: string | number | boolean }> }
}
{
  "data": {
    "resource": {
      "callId": "AC-summary",
      "processingStatus": "completed",
      "summary": ["Customer asked for pricing details."],
      "nextSteps": ["Send follow-up email."],
      "fromPhoneNumber": null,
      "handledByAiAgent": false,
      "answeredByUserId": null,
      "jobs": []
    },
    "context": {
      "phoneNumberId": "PN123",
      "conversationId": "CN123",
      "phoneNumberType": "shared",
      "userId": "US123",
      "contacts": { "ids": ["CT123"], "lookupStatus": "matched" },
      "participants": {
        "workspace": ["+15550000001"],
        "external": ["+15550000002"],
        "resolution": "available"
      }
    },
    "links": { "quo": "https://my.quo.com/inbox/..." }
  }
}
Notes:
  • summary and nextSteps are arrays when processingStatus === 'completed'; otherwise null.
  • jobs is an empty array when no AI agent job metadata is available.
  • Use callId to correlate the summary with its source call and any transcript event.

call.transcript.completed

A call transcript finished processing. Transcript and summary events are independent and may arrive in either order for the same call.
interface CallTranscriptCompletedEvent {
  type: 'call.transcript.completed'
  data: {
    resource: {
      callId: string
      createdAt: string
      duration: number
      processingStatus: 'absent' | 'in-progress' | 'completed' | 'failed'
      dialogue: DialogueEntry[] | null
    }
    context: CallContext
    links: { quo: string | null }
  }
}

interface DialogueEntry {
  userId: string | null
  identifier: string | null
  content: string
  start: number
  end: number
}
{
  "data": {
    "resource": {
      "callId": "AC-transcript",
      "createdAt": "2026-04-13T12:00:01.000Z",
      "duration": 42,
      "processingStatus": "completed",
      "dialogue": [
        { "userId": "US123", "identifier": null, "content": "Thanks for calling, how can I help?", "start": 0, "end": 3 },
        { "userId": null, "identifier": "+15550000002", "content": "Hi, I wanted to ask about pricing.", "start": 3, "end": 7 }
      ]
    },
    "context": {
      "phoneNumberId": "PN123",
      "conversationId": "CN123",
      "phoneNumberType": "shared",
      "userId": "US123",
      "contacts": { "ids": ["CT123"], "lookupStatus": "matched" },
      "participants": {
        "workspace": ["+15550000001"],
        "external": ["+15550000002"],
        "resolution": "available"
      }
    },
    "links": { "quo": "https://my.quo.com/inbox/..." }
  }
}
Not every dialogue line maps to a Quo user. External participants surface as identifier without a userId; internal speakers surface as userId with or without an identifier.

call.voicemail.completed

A voicemail was left and finished processing. resource.id is the voicemail activity id. resource.callId is the source call activity id, or null if the source call could not be resolved. Use resource.callId to correlate the voicemail with the rest of the call lifecycle.
interface CallVoicemailCompletedEvent {
  type: 'call.voicemail.completed'
  data: {
    resource: {
      id: string
      voicemailId: string | null
      callId: string | null
      direction: 'incoming' | 'outgoing'
      duration: number
      from: string
      to: string
      transcript: string | null
      recordingUrl: string | null
      createdAt: string
      updatedAt: string
    }
    context: CallContext
    links: { quo: string | null }
  }
}
{
  "data": {
    "resource": {
      "id": "AC-voicemail",
      "voicemailId": "VM123",
      "callId": "AC-source-call",
      "direction": "incoming",
      "duration": 18,
      "from": "+15550000002",
      "to": "+15550000001",
      "transcript": "Hi, leaving a quick message about the proposal...",
      "recordingUrl": "https://recordings.example.com/VM123.mp3",
      "createdAt": "2026-04-13T12:01:00.000Z",
      "updatedAt": "2026-04-13T12:01:10.000Z"
    },
    "context": {
      "phoneNumberId": "PN123",
      "conversationId": "CN123",
      "phoneNumberType": "shared",
      "userId": "US123",
      "contacts": { "ids": ["CT123"], "lookupStatus": "matched" },
      "participants": {
        "workspace": ["+15550000001"],
        "external": ["+15550000002"],
        "resolution": "available"
      }
    },
    "links": { "quo": "https://my.quo.com/inbox/..." }
  }
}
  • transcript is null while processing or if transcription was not available for the voicemail.
  • Download or persist the voicemail recording rather than relying on recordingUrl for long-term access.
  • If the source call context cannot be fully resolved, participants.resolution may be unavailable and participant arrays may be empty. If the source call itself cannot be resolved, resource.callId will be null and phoneNumberType will be null.

contact.updated

A contact was created or its fields changed. Use updatedAt for ordering and freshness checks. Contact events are workspace-wide; see Subscription rules.
interface ContactUpdatedEvent {
  type: 'contact.updated'
  data: {
    resource: ContactResource
    context: ContactContext
    links: { quo: string | null }
  }
}

interface ContactResource {
  id: string
  firstName: string | null
  lastName: string | null
  company: string | null
  role: string | null
  location: string | null
  source: string | null
  externalId: string | null
  emails: Array<{ value: string; type: 'email' }>
  phoneNumbers: Array<{ value: string; type: 'phone-number' }>
  customFields: CustomField[]
  createdAt: string
  updatedAt: string
}

type CustomField =
  | { name: string; key: string; id?: string; type: 'string' | 'url' | 'address'; value: string | null }
  | { name: string; key: string; id?: string; type: 'number'; value: number | null }
  | { name: string; key: string; id?: string; type: 'boolean'; value: boolean }
  | { name: string; key: string; id?: string; type: 'date'; value: string | null }
  | { name: string; key: string; id?: string; type: 'multi-select'; value: string[] }
{
  "data": {
    "resource": {
      "id": "CT123",
      "firstName": "Jane",
      "lastName": "Doe",
      "company": null,
      "role": null,
      "location": null,
      "source": null,
      "externalId": null,
      "emails": [{ "value": "jane@example.com", "type": "email" }],
      "phoneNumbers": [{ "value": "+15551234567", "type": "phone-number" }],
      "customFields": [
        { "name": "Department", "key": "department", "id": "i1", "type": "multi-select", "value": ["sales"] }
      ],
      "createdAt": "2026-01-01T00:00:00.000Z",
      "updatedAt": "2026-04-13T12:00:00.000Z"
    },
    "context": { "userId": "US123", "sharedWithIds": ["US456"] },
    "links": { "quo": "https://my.quo.com/contacts/CT123" }
  }
}
Notes:
  • customFields[].id is omitted when the source item has no id.
  • Invalid number or date custom field values normalize to null.
  • multi-select values are always arrays.

contact.deleted

A contact was deleted. The resource shape matches contact.updated; the event discriminator is type. Soft-deleted email addresses, phone numbers, and custom fields are removed from the payload rather than delivered with a deletion marker.
interface ContactDeletedEvent {
  type: 'contact.deleted'
  data: {
    resource: ContactResource
    context: ContactContext
    links: { quo: string | null }
  }
}
The JSON shape is identical to contact.updated, with type: "contact.deleted" on the envelope.

See also