Guide
Calendar and meetings
Two related things live here. The calendar sends real iCalendar invitations — the kind Gmail and Outlook parse into an event card with Yes / Maybe / No buttons, not a link to a web page pretending to be one. And the messaging layer runs peer-to-peer audio and video calls in the browser.
The calendar half was executed end to end: event created, invitation sent, ICS inspected, RSVP submitted and read back. The call half is described from its implementation, with the parts that have never run against real data marked as such.
Create an event
Events belong to a calendar; omitting calendar_id uses the organisation default. Listing your calendars first:
[
{ "id": "a35d9e38-de77-4c0b-89a3-cb5776c26df1",
"name": "My calendar", "color": "primary",
"is_visible": true, "is_default": true, "owner_id": 35 }
]/calendar/events201 Created{
"title": "Docs walkthrough - discovery call",
"start_at": "2026-08-12T14:00:00+00:00",
"end_at": "2026-08-12T14:30:00+00:00",
"timezone": "UTC",
"location": "Google Meet",
"meeting_url": "https://meet.google.com/abc-defg-hij",
"description": "Thirty minutes to walk through the rollout plan.",
"attendees": [
{ "email": "[email protected]", "name": "Sam Rivera" }
]
}{
"id": "56f7f24e-3a0c-4b88-9e63-92123812f11e",
"calendar_id": "a35d9e38-de77-4c0b-89a3-cb5776c26df1",
"title": "Docs walkthrough - discovery call",
"start_at": "2026-08-12T14:00:00+00:00",
"end_at": "2026-08-12T14:30:00+00:00",
"timezone": "UTC",
"status": "confirmed",
"transparency": "busy",
"attendees": [
{ "name": "Sam Rivera", "email": "[email protected]",
"status": "needs-action", "comment": null,
"invited_at": "2026-08-06T18:39:52.978100+00:00",
"responded_at": null }
],
"organizer_email": "[email protected]",
"organizer_name": "Sam Rivera",
"invites_sent_at": "2026-08-06T18:39:52.978100+00:00",
"sequence": 0,
"is_recurring": false
}invites_sent_at was already populated: creating an event with attendees sends the invitations. sequence is the iCalendar revision counter, incremented on updates so clients know to replace rather than duplicate.Recurrence uses an RFC 5545 recurrence_rule (an RRULE string). Occurrences expand server-side, and the event objects returned carry is_occurrence, is_override and exdates so a client can tell a generated instance from an edited one.
Real ICS invitations
Invitations are sent as text/calendar with an ATTENDEEline per recipient, which is what makes a mail client show its own RSVP buttons. Inspecting the calendar export shows exactly what is produced — this is the real event created above:
/calendar/export.icsBEGIN:VEVENT
UID:[email protected]
DTSTAMP:20260806T184011Z
DTSTART:20260812T140000Z
DTEND:20260812T143000Z
SUMMARY:Docs walkthrough - discovery call
DESCRIPTION:Thirty minutes to walk through the rollout plan.
LOCATION:Google Meet
URL:https://meet.google.com/abc-defg-hij
STATUS:CONFIRMED
TRANSP:OPAQUE
ATTENDEE;PARTSTAT=NEEDS-ACTION;CN=Sam Rivera:mailto:[email protected]
xample
CREATED:20260806T183952Z
LAST-MODIFIED:20260806T183957Z
SEQUENCE:0
END:VEVENTAnother real event in the same export shows alarms and mixed participation status:
ATTENDEE;PARTSTAT=NEEDS-ACTION;CN=Sam Rivera (Outlook):mailto:[email protected]
om
ATTENDEE;PARTSTAT=ACCEPTED;CN=Dana Keller (Gmail):mailto:dana.keller@northw
ind.example
BEGIN:VALARM
ACTION:DISPLAY
DESCRIPTION:SalesShift — invitation delivery test
TRIGGER:-PT10M
END:VALARMSend or re-send invitations explicitly, and read their state back:
/calendar/events/{event_id}/invites/calendar/events/{event_id}/invites{
"invites": [
{ "id": "c06ce405-4279-4392-badf-80100dda96f6",
"email": "[email protected]",
"name": "Sam Rivera",
"status": "needs-action",
"responded_at": null,
"comment": null,
"sent_at": "2026-08-06T18:39:52.978100+00:00",
"send_error": null,
"sequence_sent": 0 }
],
"sequence": 0,
"invites_sent_at": "2026-08-06T18:39:52.978100+00:00",
"organizer": { "email": "[email protected]", "name": "Sam Rivera" },
"google_url": "https://calendar.google.com/calendar/render?action=TEMPLATE&text=Docs%20walkthrough%20-%20discovery%20call&dates=20260812T140000%2F20260812T143000&ctz=UTC&…",
"outlook_url": "https://outlook.office.com/calendar/0/dee…"
}send_error per invite means one bad address does not hide itself behind an overall success. google_url and outlook_urlare “add to calendar” fallbacks for recipients whose client did not render the invitation.Handling replies
Each invitation carries a tokenised RSVP link. The buttons in the email are just that URL with a responsequery parameter, so a single click both records the reply and shows a confirmation — no form to fill in.
/calendar/rsvp/{token}no auth · ?response=acceptedReply recorded
Thank you, Sam Rivera. Your reply was recorded as Yes — you are going.
Going · Docs walkthrough - discovery call
When Wednesday, 12 August 2026 · 14:00 – 14:30 (UTC, UTC+00:00)
Where Google Meet
Join https://meet.google.com/abc-defg-hij
Organiser Sam Rivera
The organiser has been notified.
Changed your mind? Open this link again and pick a different answer.There is also a JSON-friendly submit for building your own UI, with an optional comment:
/calendar/rsvp/{token}no authcurl -s -X POST "$API/calendar/rsvp/aabc9d46…" \
-H 'Content-Type: application/json' \
-d '{"response":"accepted","comment":"Looking forward to it."}'{
"invites": [
{ "email": "[email protected]",
"status": "accepted",
"responded_at": "2026-08-06T18:40:44.446959+00:00",
"comment": "Looking forward to it.",
"sent_at": "2026-08-06T18:39:52.978100+00:00" }
]
}| response | ICS PARTSTAT |
|---|---|
accepted | ACCEPTED |
declined | DECLINED |
tentative | TENTATIVE |
| no reply yet | NEEDS-ACTION |
WebRTC calls
Calls in the messaging layer are peer-to-peer mesh. The server relays SDP and ICE and nothing else — not one audio frame passes through it while a call is live. There is deliberately no SFU: building one is a different product (a TURN fleet, per-region media servers, an on-call rotation for jitter buffers).
That architecture is the reason for everything below, including where recording happens.
curl -s "https://api.vxcloud.io/api/v1/messaging/calls/active" \
-H "Authorization: Bearer $TOKEN"
{"calls":[],"count":0}Recording requires consent from every participant
Because media never reaches the server, a recording is made in one participant’s browser— MediaRecorder over an AudioContext mix of the local microphone and every remote track — and uploaded in chunks while the call runs, so a tab that dies at minute 58 costs 58 seconds rather than 58 minutes.
Consent is not a policy here, it is a precondition enforced by the upload path:
- A recording is announced on the signalling channel beforeMediaRecorder starts. That broadcast shows a persistent indicator to every peer, rings every participant’s lobby socket, and drops an unremovable system message into the transcript.
- The announcement is written server-side, and the recording id is minted by that announcement.
- Finalising an upload whose id was never announced returns
409and the partial file is deleted. There is no override. Hidden recording is impossible rather than merely forbidden. - Only the participant who announced a recording may upload it — anyone else gets
403. - The consent roster stored on the call is the server’s record of who the indicator was pushed to and who acknowledged it, never the uploader’s claim about the room.
409 This recording was never announced to the call's participants, so it
cannot be stored. Recording must start with call-recording-start, which
shows every participant a recording indicator.Transcription and summary
Transcription and summarisation run on the tenant’s own model key, through the same integrations-plus-Vault lookup the rest of SalesShift uses.
/messaging/calls/{call_id}/intelligence/messaging/calls/{call_id}/transcribefor a recording that landed before a key was configuredCommon problems
The invitation arrived as an attachment instead of an event card
The client did not treat the part as an invitation. Usually the method or the attendee line: an invitation needs METHOD:REQUEST and an ATTENDEEline matching the recipient’s address. An ICS with no ATTENDEE is a calendar file, not an invitation, and correctly gets no RSVP buttons. Check the address on the invite matches the mailbox it was delivered to.
Some guests see the event twice after a reschedule
The UID must stay the same and SEQUENCE must increase for a client to treat the update as a replacement. Both are handled by PATCH /calendar/events/{id}. Deleting and recreating the event mints a new UID, and every guest gets a second entry that they must decline by hand.
An invite shows send_error but the event looks fine
Delivery is per attendee. The event exists and the others were invited; that one address failed. Fix the address and re-send with POST /calendar/events/{id}/invites. Invitations go out through the same sending path as everything else, so a paused mailbox or an exhausted daily ceiling will show up here too — see Deliverability.
The RSVP link says the reply was recorded, but the organiser sees needs-action
Check you are reading the invite rather than the event’s attendees array — GET /calendar/events/{id}/invites is the authoritative record and carries responded_at and comment. Also confirm the guest used the link for this event: tokens are per invitation, and an older invitation’s link records against the older event.
Times are an hour out for some attendees
start_at and end_at are absolute instants and the ICS emits UTC (DTSTART:20260812T140000Z), so clients localise correctly. An hour of drift almost always means the input was written as a wall-clock time without an offset. Always send an explicit offset, as in 2026-08-12T14:00:00+00:00.
409 when finalising a recording
The recording was never announced, so there is no consent record and the upload is refused and deleted. Start recording through the signalling call-recording-start control, which mints the recording id, and use that id for the chunk uploads. A client-minted id that the server never saw announced can never be finalised.
A recording exists but there is no transcript
Expected without an AI provider key: the intelligence record lands at status: "unavailable" with the reason. Connect a model key as an integration, then call POST /messaging/calls/{call_id}/transcribe to process a recording that arrived earlier. Note that this whole path is unproven on this deployment.
Next
Deliverability
Invitations are sent through the same pool and caps as everything else.
Sequences
A booked meeting stops an enrolment when stop_on_meeting is set.
Quotes and invoicing
Turn the meeting into a quote, and the quote into cash.
REST API reference
All 15 calendar routes plus the messaging call endpoints.