Skip to main content
Documentation

Invoices

invoices.send

Send one invoice to the customer with a view-and-pay link
WritesReaches the customerSensitiveSend budget

REST

Shipping
POST /api/v1/invoices/send

MCP tool

Live
invoices.send

Exposed on: REST API · Shop MCP server · Claude connector · Dash (in-app copilot) · Zapier. Part of the Invoices domain.

Operating contract

Mints a private view-and-pay link for the bill and delivers it. THIS REACHES THE CUSTOMER — their phone, their inbox, or both — so it is not a call to make while you are still working out whether the invoice is right.

channels picks the rails: sms, email, or both. The shop's own wording, branding and opt-out handling are applied for you; there is no message body to write, and there is no way to send a different one from here.

Only for an ISSUED, still-payable invoice. A draft is refused with 'issue it first', and a void, refunded or fully-paid bill is refused because the page the customer would land on has nothing to collect — which is a worse outcome than the refusal, since they have already been bothered.

The per-channel outcome comes back separately: sms.ok false with a reason of no_phone or opted_out means that rail was not attempted, and it is not a failure of the send. Read both before telling a shop the customer has been chased.

TEXTS CANNOT CARRY LINKS RIGHT NOW. A platform-wide guardrail is on while a carrier filtering issue is open, so the SMS half may deliver a message that points the customer at their email instead of at the link. email.ok is then the one that matters. This is a shop-level switch, not something to work around.

Sending the same invoice twice sends it twice. Read invoices.get and the shop's own history before re-sending.

Who may call it

Permission
invoices.accessThe caller must hold Invoices 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
sendCounted against the send budget, the tightest one: what a runaway loop spends here is the shop's own sending reputation.

Input

FieldTypeDescription
invoice_idrequiredstringuuid

The issued invoice to send, as returned by invoices.list or invoices.get.

channelsrequiredstring[]

Which rails to send on: sms, email, or both.

Output

FieldTypeDescription
okboolean

errorstring | null

smsobject

sms.attemptedboolean

sms.okboolean

sms.reasonstring | null

emailobject

email.attemptedboolean

email.okboolean

email.reasonstring | null

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/invoices/send \
  -H "Authorization: Bearer $SERVICEVIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"invoice_id":"9b2f1c6e-4a77-4d2b-9f31-0f1c9a8e5d20","channels":["sms"]}'

TypeScript (fetch)
const res = await fetch("https://www.servicevin.com/api/v1/invoices/send", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.SERVICEVIN_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "invoice_id": "9b2f1c6e-4a77-4d2b-9f31-0f1c9a8e5d20",
    "channels": [
      "sms"
    ]
  }),
});

// 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/invoices/send",
    headers={"Authorization": f"Bearer {os.environ['SERVICEVIN_API_KEY']}"},
    json={
    "invoice_id": "9b2f1c6e-4a77-4d2b-9f31-0f1c9a8e5d20",
    "channels": [
        "sms"
    ]
},
    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": "invoices.send",
    "arguments": {
      "invoice_id": "9b2f1c6e-4a77-4d2b-9f31-0f1c9a8e5d20",
      "channels": [
        "sms"
      ]
    }
  }
}

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 invoices.access.
403insufficient_scopeThe credential is read-only and this capability writes.
422validation_errorAn argument was wrong. The message names the field.
429rate_limitedToo many send calls. Back off and retry.
500internal_errorSomething failed on our side. Nothing was changed.