Skip to main content
Documentation

Comms

comms.schedule_message

Queue a text or email message to a customer for a later time
WritesReaches the customerSensitiveWrite budget

REST

Shipping
POST /api/v1/comms/schedule_message

MCP tool

Live
comms.schedule_message

Exposed 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

FieldTypeDescription
channelrequiredstring

Which rail to send on when the time comes.

One of: sms, email
bodyrequiredstringmax 20000 chars, min 1 chars

The message to send. On sms, any URL is removed as it is queued.

send_atrequiredstringmax 40 chars, min 4 chars

ISO instant to send at. Must be in the future; resolve it against the shop's timezone.

conversationstringuuid

Land it in this thread, as returned by comms.list_conversations.

customer_idstringuuid

Who it is for, as returned by customers.list or comms.resolve_contact. Required when no conversation is given.

subjectstringmax 200 chars

Subject line, for an email. Ignored on sms.

Output

FieldTypeDescription
idstring

conversationstring | null

customer_idstring | null

channelstring

bodystring

tostring

send_atstring

statusstring

links_removedboolean

Examples

Built from this capability's own schema — required fields and the ones carrying a default, and nothing invented. Paste one and it validates.

curl
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"}'

TypeScript (fetch)
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;

Python (requests)
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"]

MCP tools/call — https://www.servicevin.com/api/mcp
{
  "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.

StatusCodeWhen
404not_foundThe id is unknown, or the feature is not enabled for this account. Deliberately the same answer for both.
403forbiddenThis login does not hold inbox.access.
403insufficient_scopeThe credential is read-only and this capability writes.
422validation_errorAn argument was wrong. The message names the field.
429rate_limitedToo many write calls. Back off and retry.
500internal_errorSomething failed on our side. Nothing was changed.