Documentation
Comms
comms.schedule_message
REST
ShippingPOST /api/v1/comms/schedule_messageMCP tool
Livecomms.schedule_messageExposed on: REST API · Shop MCP server · Claude connector · Dash (in-app copilot) · Zapier. Part of the Comms domain.
Operating contract
Parks one message to go out at send_at instead of now. It sits in the shop's scheduled queue where staff can see it and pull it, and the sweep sends it when the time comes.
THE COMPLIANCE CHECKS HAPPEN TWICE, and the second time is the one that counts. Queuing refuses a customer who is already blocked; the sweep re-checks the block, the quiet hours and the sending number at the moment it actually sends. So a STOP that lands between now and then still stops the message, and a queued send is never a way around a refusal.
LINKS ARE STRIPPED WHEN THE MESSAGE IS QUEUED, not when it is sent. That is deliberate: this rail stores the body now, and a shop looking at its queue must be reading the text its customer will actually get. So if you queue an SMS containing a URL, the stored body already has it removed and a reply pointer appended — check the returned body rather than assuming yours went in verbatim. Email bodies are untouched; links belong there.
send_at IS AN ABSOLUTE INSTANT and it must be in the future. Resolve 'tomorrow morning' against the SHOP's timezone before you call, not yours — and if you do not know the shop's timezone, ask rather than guessing, because an hour out is the difference between a courteous nudge and a text at 6am.
Pass conversation, customer_id, or both. With neither there is nobody to send to and it is refused. comms.cancel_scheduled_message pulls one back before it fires; comms.list_scheduled_messages shows what is waiting.
Who may call it
- Permission
inbox.accessThe caller must hold Inbox at the ACT level. A read-only dashboard grant on the same section is refused.- Plan
- Every planNo plan gate. Available on every Service VIN plan.
- Retries
noneA repeat genuinely does it twice and no key can make it not. Read before you write; the operating contract says what to read.- Rate class
writeCounted against the write budget, which is tighter than a read.
Input
| Field | Type | Description |
|---|---|---|
| channelrequired | string | Which rail to send on when the time comes. One of:sms, email |
| bodyrequired | stringmax 20000 chars, min 1 chars | The message to send. On sms, any URL is removed as it is queued. |
| send_atrequired | stringmax 40 chars, min 4 chars | ISO instant to send at. Must be in the future; resolve it against the shop's timezone. |
| conversation | stringuuid | Land it in this thread, as returned by comms.list_conversations. |
| customer_id | stringuuid | Who it is for, as returned by customers.list or comms.resolve_contact. Required when no conversation is given. |
| subject | stringmax 200 chars | Subject line, for an email. Ignored on sms. |
Output
| Field | Type | Description |
|---|---|---|
| id | string | — |
| conversation | string | null | — |
| customer_id | string | null | — |
| channel | string | — |
| body | string | — |
| to | string | — |
| send_at | string | — |
| status | string | — |
| links_removed | boolean | — |
Examples
Built from this capability's own schema — required fields and the ones carrying a default, and nothing invented. Paste one and it validates.
export SERVICEVIN_API_KEY=svk_live_…
curl -X POST https://www.servicevin.com/api/v1/comms/schedule_message \
-H "Authorization: Bearer $SERVICEVIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"channel":"sms","body":"Left a voicemail about Friday.","send_at":"2026-09-05T14:20:00.000Z"}'const res = await fetch("https://www.servicevin.com/api/v1/comms/schedule_message", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SERVICEVIN_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"channel": "sms",
"body": "Left a voicemail about Friday.",
"send_at": "2026-09-05T14:20:00.000Z"
}),
});
// Success and failure are both envelopes. Switch on error.code, never
// on error.message — the codes are stable, the messages are for people.
const payload = await res.json();
if (!res.ok) throw new Error(payload.error.code);
const data = payload.data;import os, requests
res = requests.post(
"https://www.servicevin.com/api/v1/comms/schedule_message",
headers={"Authorization": f"Bearer {os.environ['SERVICEVIN_API_KEY']}"},
json={
"channel": "sms",
"body": "Left a voicemail about Friday.",
"send_at": "2026-09-05T14:20:00.000Z"
},
timeout=30,
)
payload = res.json()
if not res.ok:
raise RuntimeError(payload["error"]["code"])
data = payload["data"]{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "comms.schedule_message",
"arguments": {
"channel": "sms",
"body": "Left a voicemail about Friday.",
"send_at": "2026-09-05T14:20:00.000Z"
}
}
}Refusals
The four gates run in this order on every surface, and the order is not arbitrary — see Authentication.
| Status | Code | When |
|---|---|---|
| 404 | not_found | The id is unknown, or the feature is not enabled for this account. Deliberately the same answer for both. |
| 403 | forbidden | This login does not hold inbox.access. |
| 403 | insufficient_scope | The credential is read-only and this capability writes. |
| 422 | validation_error | An argument was wrong. The message names the field. |
| 429 | rate_limited | Too many write calls. Back off and retry. |
| 500 | internal_error | Something failed on our side. Nothing was changed. |