šŸŽ‰ New to Pro? 50% off your first 3 months billed monthly, or CA$42 off your first year billed annually Ā· Get the launch price šŸŽ‰
Why Pluto Suite Features How it works Pricing
Resources
Help
Start free → Log in

Developer reference for the Pluto Suite public API (API keys + webhooks). API keys are managed in the web app at app.plutosuite.com, under Setup → Developers (Admin or Owner).

Base URL: https://api.plutosuite.com. All request/response bodies are JSON. See the Endpoint reference below for per-resource fields.

Conventions

These hold across every endpoint below, read once, then skip to the resource you need.

  • Money is always integer cents. 1050 means $10.50. There are no decimal-dollar fields.
  • Timestamps are Unix epoch seconds (integers), e.g. 1720051200, not milliseconds, not ISO strings. (The one exception: trip startedAt/endedAt also tolerate millisecond values and normalize them.) Webhook delivery envelopes use ISO-8601, and so does startedAt in the trip.completed payload (see Webhooks).
  • IDs are opaque strings, don't parse them.
  • Unknown request fields are silently ignored (the server validates against a fixed schema and drops anything else), so a typo'd field name = silent no-op, not an error.
  • List responses wrap the array in a key: most are { "items": [...] }, but clients return { "clients": [...], "customers": [...] } (the same array under both keys) and vendors { "vendors": [...] }. Several also include a top-level count. Every list item carries id and createdAt.
  • Errors are { "error": "<code>", "msg"?: "<human message>" } with a matching HTTP status (400 bad input, 402 plan limit, 403 scope/role, 404 not found, 409 duplicate, 422 validation, 429 rate/plan cap). Most schema validation failures are 400 { "error": "validation_error", "issues": [{ "path", "message" }] }, with no msg.

Authentication

Create an API key at app.plutosuite.com, under Setup → Developers (Admin or Owner). Keys look like plt_<48 hex> and are shown once, only a SHA-256 hash is stored.

curl https://api.plutosuite.com/api/v1/invoices \
  -H "Authorization: Bearer plt_your_key_here"
  • The key is business-scoped, no x-business-id header needed (it's ignored).
  • Scopes: read allows GET/HEAD; write additionally allows POST/PUT/PATCH/DELETE. A read key calling POST gets 403 insufficient_scope.
  • Writes act as the user who created the key (audit trail shows them). If that person is removed from the business, the key stops working.
  • No 2FA and no Firebase session, the key is the whole credential. Treat it like a password; revoke immediately if leaked (revocation is instant, ≤60s cache).
  • Creating a key needs an Admin or Owner who signed in recently.
  • Limit: 10 active keys per business. Rate limit: 600 requests per minute per key (each key has its own bucket), plus a per-IP ceiling of 3,000 per minute.

Allowed surface

API keys are confined to business-data endpoints (allowlist enforced server-side; anything else returns 403 path_not_allowed):

/api/v1/invoices            /api/v1/recurring_invoices
/api/v1/clients             /api/v1/customers
/api/v1/vendors             /api/v1/bills
/api/v1/receipts            /api/v1/transactions
/api/v1/trips               /api/v1/tags
/api/v1/trip_tags           /api/v1/jobs
/api/v1/tax_rates           /api/v1/categories
/api/v1/reports/*           /api/v1/daily_close
/api/v1/me                  /api/v1/webhooks

/clients and /customers are the same resource (/clients is the main path, /customers is a permanent alias). /tags and /trip_tags are also the same list.

Inside /api/v1/reports, keys are read-only: any request other than GET, HEAD or OPTIONS (including the POST CSV exports) and everything under /api/v1/reports/t5018 return 403 path_not_allowed, because T5018 data carries contractor BN/SIN numbers. For the same reason, a vendor's taxNumber is never returned to, or saved from, an API key.

Never available to keys: billing, team management, payroll, settings, security, diagnostics. Role mapping inside the surface: write keys act as ADMIN, read keys as ACCOUNTANT, endpoint-level RBAC still applies on top.

/api/v1/jobs and /api/v1/recurring_invoices need a Pro plan; on Free they return 402.

/api/v1/webhooks (create/list/update/delete/test subscriptions) is reachable so integrations like Zapier/Make can self-manage their own webhook subscriptions with the key. It's gated by requireRole('ADMIN'), so only a write key (→ ADMIN) can manage hooks; a read key (→ ACCOUNTANT) is role-blocked here.

Polling list endpoints

For pull-based integrations (Zapier polling triggers, cron jobs), the invoices, bills, clients, vendors, receipts, transactions and trips lists accept an optional, stable sort so you can reliably page newest-first. The other endpoints (recurring invoices, tags, jobs, tax rates, categories, reports, daily close, webhooks) don't take these parameters.

  • ?sort=created, order by immutable createdAt (default when this param is present).
  • ?order=asc | ?order=desc, direction (default desc, i.e. newest first).
  • Existing limit/offset (or endpoint-specific) pagination still applies. Vendors has no pagination and returns every vendor.
  • Transactions, receipts and trips default to scope=me, meaning only rows belonging to the user who created the key. Pass ?scope=all to get the whole business, or your integration will miss data.

Omit sort and each endpoint keeps its human-facing default order (e.g. invoices by issue date, clients by name). Always dedupe on the item id, deliveries and polls are at-least-once. Every list item carries id and createdAt.

Resources

Each resource supports GET /api/v1/<resource> to list (poll-friendly, see above) and POST /api/v1/<resource> to create. GET needs a read (or write) key; POST needs a write key. By id: invoices, bills, transactions and trips support GET/PATCH/DELETE; clients and vendors support GET/PUT/DELETE; receipts support GET/PATCH only (no delete over the API).

ResourceCreate, key fields (money in cents, times in epoch seconds)
invoicescustomerName, customerEmail, type (invoice | estimate), status (draft | sent, sent issues it to the ledger), items[] { description, quantity, unitCents }, dueDate, notes. A sent invoice needs a customer email or phone. Invoices and estimates have no monthly cap on any plan.
billsvendorName or vendorId, status (open | draft), items[], issueDate, dueDate, notes.
clients (or customers)name (required), email, phone, address, paymentTermsDays, taxExempt, currency. If a client with the same email already exists (or, when you send no email, a client with the same name and no email), you get 200 with that client and "deduped": true, and no customer.created event fires.
vendorsname (required), email, phone, address, paymentTermsDays. (taxNumber is ignored for API keys, see above.)
transactionsamountCents (required), direction (EXPENSE | INCOME), description, category, occurredAt.
tripsstartedAt (defaults to now), endedAt, distanceMeters (12.4 km → 12400), classification (business | personal, anything else is saved as unclassified), purpose. The Free plan allows 30 trips a month (402 over that).

Lookups for "find or create": clients/vendors accept ?search=<name or email>; invoices accept ?number=<invoice number>.

Receipts created with an API key count toward the Free plan's 10 receipts a month, and a create over that returns 402 free_tier_limit. Receipts forwarded by email or text message don't count. Pro has no monthly receipt limit.

Example, create an invoice

curl -X POST https://api.plutosuite.com/api/v1/invoices \
  -H "Authorization: Bearer plt_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "customerName": "Acme Co",
    "customerEmail": "ap@acme.example",
    "status": "sent",
    "items": [{ "description": "Consulting", "quantity": 3, "unitCents": 15000 }]
  }'
{ "ok": true, "id": "cl9x…", "invoiceNumber": "INV-0001", ...every other invoice field, "placeOfSupply": { ... } }

Create responses return the full new record, except receipts, which return 201 { id }. Invoices add ok: true and a placeOfSupply object describing which province's tax rate was applied.

Idempotency on create

Most creates accept an optional clientRequestId (any stable unique string, one per logical create). If a create succeeds but the response is lost and you retry with the same clientRequestId, you won't get a duplicate. What comes back depends on the resource:

  • Transactions, bills, vendors, clients: 200 with the original record (a first attempt returns 201). If that record has since been deleted, you get 409 duplicate with the deleted record's existingId instead.
  • Invoices: 409 duplicate_invoice with existingId and invoiceNumber.
  • Trips: 409 duplicate_trip with existingId.
  • Receipts: 409 with existingId (JSON bodies only, not multipart uploads).

A 409 that carries existingId usually means your first attempt succeeded, so reuse that id (fetch it first if you need to be sure it still exists). An invoice 409 duplicate_invoice_in_flight means the first attempt is still in progress; retry shortly. Keys are remembered per user (the key's creator), not per API key. Recurring invoices, jobs, tax rates, categories, tags and webhooks don't support clientRequestId.

Webhooks

Create a webhook in Developers with a public https:// endpoint and a set of events. The signing secret (whsec_…) is shown once. Each business can have up to 10 webhooks.

Events (v1), payloads are a stable, additive-only contract: fields may be added, never removed or repurposed. Every payload carries the keys listed below (nullable where noted, and credit notes and refunds only carry the invoice keys when an invoice is involved).

EventFires whenPayload data
invoice.createdAny invoice is created (including drafts)invoiceId, invoiceNumber, customerName, customerEmail, totalCents, currency, type
invoice.sentAn invoice/estimate is sent through the send action (POST /api/v1/invoices/:id/send, or Send in the app). emailed is false when no email went out. Setting status to sent on create or update does not fire it.invoiceId, invoiceNumber, customerName, customerEmail, totalCents, currency, type, emailed
invoice.paidAn invoice settles, manual mark-paid, partial payments reaching zero balance, Stripe checkout, or an auto-matched e-TransferinvoiceId, invoiceNumber, customerName, totalCents, currency, paidMethod
credit_note.issuedA credit note is issuedcreditNoteId, creditNoteNumber, kind, totalCents; plus invoiceId, invoiceNumber and currency when it is issued against an invoice
refund.issuedA refund is recorded, in the app or from a Stripe refundcreditNoteId, creditNoteNumber, kind=refund, totalCents; plus invoiceId, invoiceNumber and currency when it is against an invoice (Stripe refunds add source=stripe)
estimate.acceptedAn estimate is accepted (customer-facing accept link, or marked accepted internally)invoiceId, invoiceNumber, customerName, totalCents, currency, type=estimate
customer.createdA client is created (not when a create is deduped onto an existing client)customerId, name, email
vendor.createdA vendor is createdvendorId, name, email
bill.createdA vendor bill is createdbillId, vendorName, totalCents, currency, status
bill.paidA bill reaches paid-in-fullbillId, vendorName, totalCents, currency, status=paid
receipt.parsedOCR extracts usable fields from a new receipt (any intake path: app, email, SMS)receiptId, merchant, amountCents, occurredAt, category, potentialDuplicateId
transaction.createdA single transaction is created via app/API (NOT bulk bank/CSV/Shopify imports, those are suppressed to avoid webhook storms)transactionId, description, amountCents, direction, category, reviewed
transaction.categorizedA transaction is reviewed/categorized (transitions to reviewed)transactionId, description, amountCents, direction, category, reviewed=true
trip.completedA mileage trip is saved (trips are recorded already finished, so this fires on create)tripId, distanceKm, classification (nullable, business | personal | commute, where commute is only ever set by Pluto's auto-classification), startedAt (ISO-8601 string, not epoch seconds)
daily_close.completedThe nightly close (around 3 AM server time) runs for your business. It only runs for businesses that still have unreviewed transactions, receipts, trips or issued invoices, so a business with nothing to review gets no event.autoCategorized, autoReviewed, pending{...}, closedThrough (epoch seconds, nullable)

Test delivery, POST /api/v1/webhooks/:id/test { event? } sends a synthetic payload (ids prefixed test_, data.test = true) to that subscription so you can validate your endpoint + signature check without waiting for a real event. Defaults to the subscription's first subscribed event. Returns { ok, event, deliveredStatus }.

Delivery format, POST, JSON body:

{
  "id": "6f0c…",              // unique delivery id
  "event": "invoice.paid",
  "createdAt": "2026-07-04T21:00:00.000Z",
  "data": { ... }
}

Verify the signature (always do this):

const crypto = require('node:crypto')
const expected = 'sha256=' + crypto.createHmac('sha256', WHSEC).update(rawBody).digest('hex')
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(req.headers['x-pluto-signature']))

Semantics: at-least-once, fire-and-forget, 5-second timeout, no automatic retries in v1 (a failed delivery increments the visible failure counter, poll the API to reconcile if you miss one). Respond 2xx quickly; do slow work async. URLs must be public https (no localhost/private ranges).

MCP server

An MCP (Model Context Protocol) server over this API is published on npm as pluto-mcp-server. It's a local stdio server that authenticates with a plt_ key and lets an AI assistant such as Claude Desktop or Cursor query and act on the books. There's nothing to install: add this to your assistant's MCP config and restart it.

{
  "mcpServers": {
    "pluto": {
      "command": "npx",
      "args": ["-y", "pluto-mcp-server"],
      "env": { "PLUTO_API_KEY": "plt_your_key_here" }
    }
  }
}
  • PLUTO_API_KEY is required. PLUTO_API_BASE is optional and defaults to https://api.plutosuite.com.
  • Read tools: list_invoices, get_invoice, list_receipts, list_transactions, list_trips, list_customers, list_vendors, list_bills, list_jobs, get_report (summary, P&L, GST, cash flow).
  • Write tools (need a write key): create_customer, create_invoice.
  • The key stays on your machine and is only sent to the Pluto API. All the allowlist and role rules above still apply. list_transactions, list_receipts and list_trips use the default scope=me, so they only see the key creator's own rows.