Developer reference for the Pluto Suite public API (API keys + webhooks). API keys are managed in the dashboard at Settings ā Developers (Admin+).
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.
1050means$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: tripstartedAt/endedAtalso tolerate millisecond values and normalize them.) Webhook delivery envelopes use ISO-8601 (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 customers return{ "customers": [...] }and vendors{ "vendors": [...] }. Several also include a top-levelcount. Every list item carriesidandcreatedAt. - Errors are
{ "error": "<code>", "msg"?: "<human message>" }with a matching HTTP status (400bad input,402plan limit,403scope/role,404not found,409duplicate,422validation,429rate/plan cap).
Authentication
Create an API key in plutosuite.com ā Developers. 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-idheader needed (it's ignored). - Scopes:
readallows GET/HEAD;writeadditionally allows POST/PATCH/DELETE. A read key calling POST gets403 insufficient_scope. - Writes act as the user who created the key (audit trail shows them).
- 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).
- Limit: 10 active keys per business. Rate limit: shared 300 req/min per IP.
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/customers /api/v1/vendors
/api/v1/bills /api/v1/receipts
/api/v1/transactions /api/v1/trips
/api/v1/trip_tags /api/v1/jobs
/api/v1/tax_rates /api/v1/categories
/api/v1/reports/* /api/v1/daily_close
/api/v1/webhooks
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/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), every list endpoint above accepts an optional, stable sort so you can reliably page newest-first:
?sort=created, order by immutablecreatedAt(default when this param is present).?order=asc|?order=desc, direction (defaultdesc, i.e. newest first).- Existing
limit/offset(or endpoint-specific) pagination still applies.
Omit sort and each endpoint keeps its human-facing default order (e.g. invoices by issue date, customers 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 (paginated, poll-friendly, see above) and POST /api/v1/<resource> to create. GET needs a read (or write) key; POST needs a write key.
| Resource | Create, key fields (money in cents, times in epoch seconds) |
|---|---|
invoices | customerName, customerEmail, type (invoice | estimate), status (draft | sent, sent issues it to the ledger), items[] { description, quantity, unitCents }, dueDate, notes. Requires a Pro plan. |
bills | vendorName or vendorId, status (open | draft), items[], issueDate, dueDate, notes. |
customers | name (required), email, phone, address, paymentTermsDays, taxExempt, currency. |
vendors | name (required), email, phone, address, paymentTermsDays, taxNumber. |
transactions | amountCents (required), direction (EXPENSE | INCOME), description, category, occurredAt. |
trips | startedAt (required), endedAt, distanceMeters (12.4 km ā 12400), classification (business | personal), purpose. |
Lookups for "find or create": customers/vendors accept ?search=<name or email>; invoices accept ?number=<invoice number>.
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" }
Create responses return the new record (or, for invoices, { ok, id, invoiceNumber }, re-fetch via GET /api/v1/invoices?number=INV-0001 for the full object).
Idempotency on create
Every POST accepts 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, the server returns the original record instead of a duplicate, a 409 whose body carries existingId. Reuse that id rather than treating it as an error.
Webhooks
Create a webhook in Developers with a public https:// endpoint and a set of events. The signing secret (whsec_ā¦) is shown once.
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).
| Event | Fires when | Payload data |
|---|---|---|
invoice.created | Any invoice is created (including drafts) | invoiceId, invoiceNumber, customerName, customerEmail, totalCents, currency, type |
invoice.sent | An invoice/estimate is emailed or marked sent | invoiceId, invoiceNumber, customerName, customerEmail, totalCents, currency, type, emailed |
invoice.paid | An invoice settles, manual mark-paid, partial payments reaching zero balance, Stripe checkout, or an auto-matched e-Transfer | invoiceId, invoiceNumber, customerName, totalCents, currency, paidMethod |
estimate.accepted | An estimate is accepted (customer-facing accept link, or marked accepted internally) | invoiceId, invoiceNumber, customerName, totalCents, currency, type=estimate |
customer.created | A customer is created | customerId, name, email |
vendor.created | A vendor is created | vendorId, name, email |
bill.created | A vendor bill is created | billId, vendorName, totalCents, currency, status |
bill.paid | A bill reaches paid-in-full | billId, vendorName, totalCents, currency, status=paid |
receipt.parsed | OCR extracts usable fields from a new receipt (any intake path: app, email, SMS) | receiptId, merchant, amountCents, occurredAt, category, potentialDuplicateId |
transaction.created | A 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.categorized | A transaction is reviewed/categorized (transitions to reviewed) | transactionId, description, amountCents, direction, category, reviewed=true |
trip.completed | A mileage trip is finalized | tripId, distanceKm, classification, startedAt |
daily_close.completed | The nightly 3 AM close finishes for your business (fires every night, even quiet ones) | autoCategorized, autoReviewed, pending{...}, closedThrough |
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 available, a local stdio server that authenticates with a plt_ key and lets Claude/Cursor/ChatGPT query and act on the books (list_invoices, get_report, create_invoice, ā¦). Contact us at support@plutosuite.com to get set up.