Saku

API

REST API for boards, cards, team-visible notes, CRM, time, expenses, and invoices. Available on the Max plan. Create API keys in your team settings (Boards → Settings → API).

For AI assistants via the Model Context Protocol, use the @florianindustries/saku-mcp package on npm (registry) with the same API keys and scopes. See the MCP server docs.

Authentication

Send your API key in the Authorization header as a Bearer token.

Authorization: Bearer YOUR_API_KEY

Rate limits

Saku REST API keys are limited to 120 requests per 60-second window. Authenticated responses include the standard RateLimit-Policy and RateLimit-Limit headers. A 429 response also includes RateLimit, RateLimit-Remaining, RateLimit-Reset, and Retry-After. Wait for the stated number of seconds before retrying. Do not automatically retry a write unless it is safe and idempotent.

Base URL

All endpoints are relative to saku.ie, e.g. https://saku.ie/api/v1.

Versioning and deprecation

Saku versions its REST API in the URL. The current stable version is v1. We make compatible additions within a version. Before removing or changing behaviour incompatibly, we announce the change at least 90 days ahead. Deprecated operations include Deprecation: true,Sunset, and a link to this policy in their responses.

Endpoints

List all boards for the team associated with your API key. Requires boards:read. Returns boards with lists, labels, and board-level member roles. The teamMembers field (names and emails) is included only when the key also has members:read ; use GET /api/v1/members for the member directory.

Response (200)

{
  "boards": [
    {
      "id": "board_abc",
      "name": "My Board",
      "description": null,
      "lists": [
        { "id": "list_xyz", "name": "To Do", "position": 0 }
      ],
      "labels": [
        { "id": "label_1", "name": "Bug", "colour": "#ef4444" }
      ],
      "members": [{ "userId": "user_1", "role": "EDITOR" }]
    }
  ],
  "teamMembers": [
    { "id": "user_1", "name": "Jane", "email": "[email protected]" }
  ]
}

The example above includes teamMembers as returned when the key has members:read.

GET/api/v1/members

List team members with IDs, names, and emails. Requires the members:read permission (separate from boards:read).

Response (200)

{
  "members": [
    { "id": "user_1", "name": "Jane", "email": "[email protected]" }
  ]
}
GET/api/v1/boards/:id

Get a specific board with all its lists and cards. Requires the boards:read permission. The teamMembers field is present only when the key includes members:read.

Response (200)

{
  "id": "board_abc",
  "name": "My Board",
  "description": null,
  "lists": [
    {
      "id": "list_xyz",
      "name": "To Do",
      "position": 0,
      "cards": [
        {
          "id": "card_1",
          "title": "Fix login bug",
          "description": "Happens on mobile",
          "position": 0,
          "priority": "HIGH",
          "dueDate": "2026-04-15T00:00:00.000Z",
          "assignees": [{ "userId": "user_1" }],
          "labels": [{ "id": "label_1", "name": "Bug", "colour": "#ef4444" }]
        }
      ]
    }
  ],
  "labels": [{ "id": "label_1", "name": "Bug", "colour": "#ef4444" }],
  "members": [{ "userId": "user_1", "role": "EDITOR" }],
  "teamMembers": [{ "id": "user_1", "name": "Jane", "email": "[email protected]" }]
}
GET/api/v1/cards/:id

Get full details for a card, including checklists, assignees, and labels. Requires the cards:read permission.

Response (200)

{
  "id": "card_1",
  "title": "Fix login bug",
  "description": "Happens on mobile",
  "listId": "list_xyz",
  "position": 0,
  "priority": "HIGH",
  "dueDate": "2026-04-15T00:00:00.000Z",
  "createdAt": "2026-01-01T12:00:00.000Z",
  "updatedAt": "2026-01-02T08:00:00.000Z",
  "createdViaApiSource": null,
  "assignees": [{ "userId": "user_1" }],
  "labels": [{ "id": "label_1", "name": "Bug", "colour": "#ef4444" }],
  "checklists": [
    {
      "id": "cl_1",
      "name": "Acceptance criteria",
      "items": [
        { "id": "cli_1", "content": "Reproduces on iOS", "completed": true, "position": 0 }
      ]
    }
  ]
}
PATCH/api/v1/cards/:id

Update a card. Only send the fields you want to change. Requires the cards:update permission.

Request body

FieldTypeDescription
titlestringNew card title
descriptionstringNew card description
prioritystring | nullCRITICAL, URGENT, HIGH, MEDIUM, LOW, or MINOR. Pass null to clear.
dueDatestring | nullISO 8601 date string, or null to clear

Response (200)

{
  "id": "card_1",
  "title": "Fixed login bug",
  "description": "Happens on mobile",
  "listId": "list_xyz",
  "position": 0,
  "priority": "MEDIUM",
  "dueDate": null,
  "updatedAt": "2026-04-01T10:30:00.000Z"
}
DELETE/api/v1/cards/:id

Archive a card (soft remove). The card is hidden from the board but can be restored in the Saku app. Requires the cards:delete permission.

Response (200)

{ "success": true, "archived": true }

Returns 404 if the card does not exist, belongs to another team, or is already archived. Returns 401 without a valid API key and 403 without the cards:delete scope.

POST/api/v1/lists

Create a new list (column) in a board. The list is appended after existing lists. Requires the lists:create permission.

Request body

FieldTypeRequiredDescription
boardIdstringYesBoard to add the list to
namestringYesList name

Response (201)

{
  "id": "list_new",
  "name": "Done",
  "position": 3,
  "boardId": "board_abc"
}

Create a card in a list. Use the boards endpoint to get valid listId, label ids, and assignee user ids (or emails).

Request body

FieldTypeRequiredDescription
listIdstringYesList ID (from GET /boards)
titlestringYesCard title
contentstringNoCard description
labelstringNoSingle label ID
labelsstring[]NoLabel IDs
assigneestringNoUser ID or email
assigneesstring[]NoUser IDs or emails
sourcestringNoOptional label shown as "Created via API (source)"

Example request

POST /api/v1/cards
Content-Type: application/json
Authorization: Bearer YOUR_API_KEY

{
  "listId": "list_xyz",
  "title": "New task",
  "content": "Description here",
  "label": "label_1",
  "assignee": "user_1",
  "source": "Contact Us Form"
}

Response (201)

{
  "id": "card_abc",
  "title": "New task",
  "description": "Description here",
  "listId": "list_xyz",
  "position": 0,
  "createdViaApiSource": "Contact Us Form",
  "assignees": [{ "userId": "user_1" }],
  "labels": [{ "id": "label_1", "name": "Bug", "colour": "#ef4444" }]
}

Notes

Team-visible notes only. A team API key can list and read notes in team vaults (vault visibility TEAM, not archived or deleted). Notes marked private are excluded even inside a team vault. Personal vaults are never exposed. Creating notes requires the API key to have an associated creator user (createdByUserId). See the Notes product guide for vaults, wiki links, and sharing behaviour.

GET/api/v1/notes

List team-visible notes with pagination. Also returns writable team vaults (for discovering vaultId before create). Requires notes:read.

Query: vaultId (optional), limit (default 100, max 500), offset.

Response (200)

{
  "notes": [
    {
      "id": "note_abc",
      "title": "Meeting notes",
      "vaultId": "vault_xyz",
      "folderId": null,
      "isPinned": false,
      "createdAt": "2026-06-01T10:00:00.000Z",
      "updatedAt": "2026-06-10T14:30:00.000Z"
    }
  ],
  "vaults": [{ "id": "vault_xyz", "name": "Team wiki" }],
  "limit": 100,
  "offset": 0
}
GET/api/v1/notes/:id

Get a note including full contentMarkdown. Requires notes:read.

Response (200)

{
  "id": "note_abc",
  "title": "Meeting notes",
  "contentMarkdown": "# Summary\n\nSee [[note:other-id]]",
  "vaultId": "vault_xyz",
  "folderId": null,
  "isPinned": false,
  "createdAt": "2026-06-01T10:00:00.000Z",
  "updatedAt": "2026-06-10T14:30:00.000Z"
}
POST/api/v1/notes

Create a note in a team vault. Wiki-links in contentMarkdown (e.g. [[note:ID]]) are synced automatically. Requires notes:create.

Request body

{
  "vaultId": "vault_xyz",
  "title": "Optional title",
  "contentMarkdown": "# Body in Markdown"
}

vaultId is required. Title defaults to "Untitled" when omitted.

Response (201)

{
  "note": {
    "id": "note_new",
    "title": "Optional title",
    "vaultId": "vault_xyz",
    "createdAt": "2026-06-11T09:00:00.000Z"
  }
}
PATCH/api/v1/notes/:id

Update title and/or content. Send only fields to change; contentMarkdown replaces the entire body (read-modify-write via GET first). Requires notes:update.

Request body

{
  "title": "New title",
  "contentMarkdown": "Updated body"
}

At least one field is required.

CRM

Accounts, contacts, leads, and deals for the team associated with your API key. Read endpoints require crm:read; create and update require crm:write. Sensitive administration needs additional Max API-key scopes (crm:export, crm:import, crm:merge, crm:forecast, crm:automation); existing keys are not broadened automatically. Human OWNER/ADMIN callers may export, import and merge on Pro+. The workspace must be on Pro or Max (effective plan, including trial), and write callers must be OWNER, ADMIN, or MEMBER. VIEWER and GUEST roles are read-only. Free or downgraded workspaces receive 403 with CRM requires Pro or Max. After a Max downgrade, workflow definitions and run history stay readable on Pro, but automation writes and execution require Max again. Monetary values use cents (e.g. 50000 = €500.00). See the CRM product guide for accounts, pipeline, and linked notes in the app.

GET/api/v1/crm/accounts

List CRM accounts (companies) with pagination.

Query: limit, cursor. Pass nextCursor from the previous response.

Response (200)

{
  "accounts": [
    {
      "id": "account_abc",
      "companyName": "Acme Ltd",
      "primaryEmail": "[email protected]",
      "phone": "+353 1 234 5678",
      "website": "https://acme.ie",
      "status": "ACTIVE",
      "healthScore": 80,
      "lastInteractionAt": "2026-06-01T12:00:00.000Z",
      "createdAt": "2026-01-15T09:00:00.000Z",
      "updatedAt": "2026-06-10T14:00:00.000Z",
      "contactsCount": 2,
      "leadsCount": 1,
      "dealsCount": 0
    }
  ],
  "limit": 100,
  "nextCursor": null
}
GET/api/v1/crm/accounts/:id

Get an account by ID, including its contacts.

Response (200)

{
  "id": "account_abc",
  "companyName": "Acme Ltd",
  "primaryEmail": "[email protected]",
  "phone": "+353 1 234 5678",
  "website": "https://acme.ie",
  "billingAddress": null,
  "accountNotes": null,
  "status": "ACTIVE",
  "healthScore": 80,
  "lastInteractionAt": "2026-06-01T12:00:00.000Z",
  "createdAt": "2026-01-15T09:00:00.000Z",
  "updatedAt": "2026-06-10T14:00:00.000Z",
  "contacts": [
    {
      "id": "contact_xyz",
      "name": "Jane Smith",
      "email": "[email protected]",
      "jobTitle": "Director",
      "isPrimary": true
    }
  ]
}
POST/api/v1/crm/accounts

Create a CRM account.

{
  "companyName": "Acme Ltd",
  "primaryEmail": "[email protected]",
  "phone": "+353 1 234 5678",
  "website": "https://acme.ie"
}

Response (201)

{
  "account": {
    "id": "account_new",
    "companyName": "Acme Ltd",
    "createdAt": "2026-06-11T10:00:00.000Z"
  }
}
GET/api/v1/crm/contacts

List contacts with optional account filter.

Query: clientId (account ID), limit, cursor. Pass nextCursor from the previous response.

Response (200)

{
  "contacts": [
    {
      "id": "contact_xyz",
      "name": "Jane Smith",
      "email": "[email protected]",
      "phone": "+353 87 123 4567",
      "jobTitle": "Director",
      "clientId": "account_abc",
      "isPrimary": true,
      "createdAt": "2026-02-01T09:00:00.000Z",
      "updatedAt": "2026-06-05T11:00:00.000Z"
    }
  ],
  "limit": 100,
  "nextCursor": null
}
POST/api/v1/crm/contacts

Create a contact. Optionally link to an account via clientId.

{
  "name": "Jane Smith",
  "clientId": "account_abc",
  "email": "[email protected]",
  "phone": "+353 87 123 4567",
  "jobTitle": "Director"
}

Response (201)

{
  "contact": {
    "id": "contact_new",
    "name": "Jane Smith",
    "clientId": "account_abc",
    "createdAt": "2026-06-11T10:00:00.000Z"
  }
}
GET/api/v1/crm/stages

List lead and deal pipeline stages. Call before creating or moving leads/deals to discover stage IDs.

{
  "leadStages": [
    { "id": "stage_1", "name": "New", "colour": "#14b8a6", "position": 0, "isDefault": true }
  ],
  "dealStages": [
    { "id": "stage_2", "name": "Proposal", "colour": "#0d9488", "position": 0, "isDefault": true }
  ]
}
GET/api/v1/crm/leads

List leads with optional filters.

Query: stageId, accountId, status (OPEN, QUALIFIED, LOST), limit, cursor. Pass nextCursor from the previous response.

Response (200)

{
  "leads": [
    {
      "id": "lead_abc",
      "title": "Website enquiry",
      "status": "OPEN",
      "stageId": "stage_1",
      "accountId": "account_abc",
      "contactId": "contact_xyz",
      "source": "Website",
      "createdAt": "2026-06-01T10:00:00.000Z",
      "updatedAt": "2026-06-10T14:00:00.000Z"
    }
  ],
  "limit": 100,
  "nextCursor": null
}
POST/api/v1/crm/leads

Create a lead. Omit stageId to use the default lead stage.

{
  "title": "Website enquiry",
  "description": "Interested in Pro plan",
  "source": "Website",
  "stageId": "stage_1",
  "accountId": "account_abc",
  "contactId": "contact_xyz"
}

When both accountId and contactId are sent, the contact must belong to that account.

Response (201)

{
  "lead": {
    "id": "lead_new",
    "title": "Website enquiry",
    "stageId": "stage_1",
    "createdAt": "2026-06-11T10:00:00.000Z"
  }
}
PATCH/api/v1/crm/leads/:id

Update a lead. Send only fields to change; at least one field required. Status: OPEN, QUALIFIED, LOST.

Response (200)

{
  "id": "lead_abc",
  "title": "Qualified enquiry",
  "status": "QUALIFIED",
  "stageId": "stage_2",
  "accountId": "account_abc",
  "contactId": "contact_xyz",
  "source": "Website",
  "createdAt": "2026-06-01T10:00:00.000Z",
  "updatedAt": "2026-06-11T10:30:00.000Z"
}
GET/api/v1/crm/deals

List deals with optional filters.

Query: stageId, accountId, status (OPEN, WON, LOST), limit, cursor. Pass nextCursor from the previous response.

Response (200)

{
  "deals": [
    {
      "id": "deal_abc",
      "title": "Annual support contract",
      "status": "OPEN",
      "stageId": "stage_2",
      "accountId": "account_abc",
      "contactId": "contact_xyz",
      "leadId": "lead_abc",
      "valueCents": 1200000,
      "closeDate": "2026-09-30T00:00:00.000Z",
      "createdAt": "2026-06-01T10:00:00.000Z",
      "updatedAt": "2026-06-10T14:00:00.000Z"
    }
  ],
  "limit": 100,
  "nextCursor": null
}
POST/api/v1/crm/deals

Create a deal. Omit stageId to use the default deal stage.

{
  "title": "Annual support contract",
  "description": "12-month renewal",
  "stageId": "stage_2",
  "accountId": "account_abc",
  "contactId": "contact_xyz",
  "leadId": "lead_abc",
  "valueCents": 1200000,
  "closeDate": "2026-09-30"
}

Response (201)

{
  "deal": {
    "id": "deal_new",
    "title": "Annual support contract",
    "stageId": "stage_2",
    "createdAt": "2026-06-11T10:00:00.000Z"
  }
}
PATCH/api/v1/crm/deals/:id

Update a deal. Send only fields to change; at least one field required. Status: OPEN, WON, LOST. Pass closeDate: null to clear the close date.

Response (200)

{
  "id": "deal_abc",
  "title": "Annual support contract",
  "status": "WON",
  "stageId": "stage_3",
  "accountId": "account_abc",
  "contactId": "contact_xyz",
  "leadId": "lead_abc",
  "valueCents": 1200000,
  "closeDate": "2026-09-30T00:00:00.000Z",
  "createdAt": "2026-06-01T10:00:00.000Z",
  "updatedAt": "2026-06-11T10:30:00.000Z"
}

Time

Time entries for the API key owner on the team associated with the key. List and read require time:read; create, start, and stop require time:write. The key must have an associated creator user (createdByUserId). Durations are in minutes. See the Time product guide for timers, timesheets, and imports in the app.

GET/api/v1/time-entries

List recent time entries for the API key owner. Requires time:read.

Query: limit (default 100, max 500), offset.

Response (200)

{
  "entries": [
    {
      "id": "time_abc",
      "userId": "user_1",
      "teamId": "team_xyz",
      "cardId": "card_1",
      "cardTitle": "Ship API docs",
      "description": "Writing endpoint docs",
      "durationMinutes": 45,
      "startTime": "2026-07-14T09:00:00.000Z",
      "endTime": "2026-07-14T09:45:00.000Z",
      "isBillable": true,
      "source": "MANUAL",
      "links": [
        {
          "targetId": "card_1",
          "targetType": "CARD",
          "targetTeamId": null,
          "isPrimary": true
        }
      ],
      "tagNames": ["docs"],
      "tags": [{ "name": "docs", "color": "#10B981" }]
    }
  ],
  "limit": 100,
  "offset": 0
}
GET/api/v1/time-entries/:id

Get a single time entry owned by the API key user. Requires time:read.

Response (200)

{
  "entry": {
    "id": "time_abc",
    "userId": "user_1",
    "teamId": "team_xyz",
    "cardId": null,
    "cardTitle": null,
    "description": "Client call",
    "durationMinutes": 30,
    "startTime": "2026-07-14T10:00:00.000Z",
    "endTime": "2026-07-14T10:30:00.000Z",
    "isBillable": true,
    "source": "MANUAL",
    "links": [],
    "tagNames": [],
    "tags": []
  }
}
POST/api/v1/time-entries

Create a completed (manual) time entry. Provide either startTime and endTime, or durationMinutes (counted backwards from now). Requires time:write.

Request body

{
  "description": "Design review",
  "startTime": "2026-07-14T11:00:00.000Z",
  "endTime": "2026-07-14T12:00:00.000Z",
  "isBillable": true,
  "cardId": "card_1",
  "tagNames": ["design"],
  "links": [
    {
      "targetId": "card_1",
      "targetType": "CARD",
      "isPrimary": true
    }
  ]
}

Optional fields: durationMinutes, source (MANUAL or IMPORT), isBillable, cardId, tagNames, links.

Response (201)

{
  "entry": {
    "id": "time_new",
    "userId": "user_1",
    "teamId": "team_xyz",
    "description": "Design review",
    "durationMinutes": 60,
    "startTime": "2026-07-14T11:00:00.000Z",
    "endTime": "2026-07-14T12:00:00.000Z",
    "source": "MANUAL",
    "links": [],
    "tagNames": ["design"],
    "tags": [{ "name": "design", "color": "#F59E0B" }]
  }
}
POST/api/v1/time-entries/start

Start a running timer for the API key owner. Only one active timer is allowed at a time. Requires time:write.

Request body

{
  "description": "Pairing session",
  "cardId": "card_1",
  "isBillable": true,
  "tagNames": ["pairing"]
}

All fields are optional. Empty body starts an untitled timer.

Response (201)

{
  "entry": {
    "id": "time_running",
    "userId": "user_1",
    "teamId": "team_xyz",
    "description": "Pairing session",
    "durationMinutes": null,
    "startTime": "2026-07-14T13:00:00.000Z",
    "endTime": null,
    "source": "TIMER",
    "links": [],
    "tagNames": ["pairing"],
    "tags": [{ "name": "pairing", "color": "#3B82F6" }]
  }
}
POST/api/v1/time-entries/stop

Stop the active timer (or a specific running entry via timeEntryId). Requires time:write.

Request body

{
  "description": "Optional final description",
  "timeEntryId": "time_running",
  "roundingMinutes": 15
}

Optional: cardId, description, timeEntryId, roundingMinutes (0, 6, or 15).

Response (200)

{
  "entry": {
    "id": "time_running",
    "durationMinutes": 48,
    "startTime": "2026-07-14T13:00:00.000Z",
    "endTime": "2026-07-14T13:48:00.000Z",
    "source": "TIMER"
  }
}

Expenses

Expense claims visible to the API key owner. Reads require expenses:read; creation, edits, workflow decisions, reimbursement, deletion, and invoice attachment require expenses:write. The key must have an associated team member. Role, plan, board-admin review policy, receipt rules, and record visibility are enforced exactly as they are in the app. See the Expenses product guide.

GET/api/v1/expenses

List visible expenses. Filter with scope ( all, mine, approvals, or reimbursements), status, clientId, boardId, cardId, query, and limit.

{
  "expenses": [{
    "id": "expense_abc",
    "title": "Train to customer workshop",
    "amountCents": "4250",
    "quantity": 1,
    "totalCents": "4250",
    "currency": "EUR",
    "status": "SUBMITTED",
    "reimbursable": true,
    "isBillableToClient": true,
    "claimant": { "id": "user_1", "name": "Jane" },
    "client": { "id": "client_1", "name": "Acme Ltd" },
    "board": { "id": "board_1", "name": "Acme delivery" },
    "card": { "id": "card_1", "title": "On-site workshop" }
  }],
  "summary": { "totalCount": 1, "awaitingApprovalCount": 1 },
  "context": { "role": "ADMIN", "canCreate": true, "canReviewAny": true }
}
POST/api/v1/expenses

Create a draft or submit immediately. Money is an integer cent string. Mutations require an Idempotency-Key header.

{
  "title": "Train to customer workshop",
  "amountCents": "4250",
  "quantity": 1,
  "currency": "EUR",
  "category": "TRAVEL",
  "incurredAt": "2026-08-09T12:00:00.000Z",
  "clientId": "client_1",
  "boardId": "board_1",
  "cardId": "card_1",
  "reimbursable": true,
  "isBillableToClient": true,
  "receiptFileId": "file_1",
  "submit": true
}

GET /api/v1/expenses/:id

Return one visible expense with receipt metadata, review details, invoice source, capabilities, and complete activity history.

PATCH /api/v1/expenses/:id

Replace editable draft or returned-claim fields using the same body as create. Claimants can edit their own editable claims; admins can reassign the claimant and linked entities.

POST /api/v1/expenses/:id/transition

Send action as submit, approve, reject, pay, or reopen. Reject requires a note. Pay accepts an optional paymentReference. Invalid status changes return a fixed workflow error.

POST /api/v1/expenses/:id/invoice

Send { "invoiceId": "inv_1" } to add an approved or paid billable expense to a matching draft invoice. Duplicate billing, client mismatch, and currency mismatch are rejected.

Invoices

Team invoices for the API key's team. List and read require invoices:read; create, update, void, and send require invoices:write. Money amounts are integer cent strings (for example "5000" for 50.00 in the invoice currency). Supported currencies: EUR, USD, GBP. Send requires the API key owner to be a team owner or admin. See the Invoicing product guide for composing, branding, and payments in the app. MCP tools: list_invoices, get_invoice, create_invoice, send_invoice.

GET/api/v1/invoices

List invoices with cursor pagination. Requires invoices:read.

Query: limit (default 50, max 100), cursor, clientId, status (DRAFT, SENT, VIEWED, PAID, OVERDUE, VOID).

Response (200)

{
  "invoices": [
    {
      "id": "inv_abc",
      "number": "INV-2026-0001",
      "status": "SENT",
      "clientId": "client_1",
      "clientName": "Acme Ltd",
      "currency": "EUR",
      "totalCents": "12300",
      "issueDate": "2026-07-15T10:00:00.000Z",
      "dueDate": "2026-07-29T00:00:00.000Z",
      "updatedAt": "2026-07-15T10:05:00.000Z"
    }
  ],
  "limit": 50,
  "cursor": null
}
POST/api/v1/invoices

Create a draft invoice for a CRM client, optionally with manual lines. Requires invoices:write. The key must have an associated creator user.

Request body

{
  "clientId": "client_1",
  "currency": "EUR",
  "lines": [
    {
      "description": "Design review",
      "quantity": 2,
      "unitCents": "7500",
      "vatRatePercent": 23
    }
  ]
}

Response (201)

{
  "invoice": {
    "id": "inv_abc",
    "status": "DRAFT",
    "number": null,
    "clientId": "client_1",
    "currency": "EUR",
    "subtotalCents": "15000",
    "taxCents": "3450",
    "totalCents": "18450",
    "templateKey": "classic",
    "payUrl": "https://saku.ie/i/team_xyz/tok_…"
  },
  "lines": [
    {
      "id": "line_1",
      "kind": "MANUAL",
      "description": "Design review",
      "quantity": "2",
      "unitCents": "7500",
      "amountCents": "15000",
      "vatRatePercent": "23",
      "position": 0
    }
  ]
}
GET/api/v1/invoices/:id

Get one invoice with lines, payments, activity, client summary, and payUrl. Requires invoices:read.

Response (200)

{
  "invoice": {
    "id": "inv_abc",
    "status": "SENT",
    "number": "INV-2026-0001",
    "currency": "EUR",
    "totalCents": "18450",
    "amountPaidCents": "0",
    "payUrl": "https://saku.ie/i/team_xyz/tok_…"
  },
  "lines": [],
  "payments": [],
  "activity": [],
  "client": {
    "id": "client_1",
    "companyName": "Acme Ltd"
  }
}
PATCH/api/v1/invoices/:id

Update draft invoice metadata and/or replace manual lines. Requires invoices:write. Only drafts can be edited.

Request body

{
  "notes": "Net 14",
  "paymentTermsDays": 14,
  "dueDate": "2026-07-29T00:00:00.000Z",
  "discountCents": "1000",
  "discountPercent": null,
  "templateKey": "classic",
  "defaultVatRatePercent": 23,
  "lines": [
    {
      "description": "Retainer",
      "quantity": 1,
      "unitCents": "50000",
      "vatRatePercent": 23
    }
  ]
}

templateKey: classic, compact, or bold. Sending lines replaces all manual lines on the draft.

DELETE/api/v1/invoices/:id

Void an invoice (does not hard-delete). The invoice number stays reserved. Requires invoices:write.

Response (200)

{
  "invoice": {
    "id": "inv_abc",
    "status": "VOID",
    "number": "INV-2026-0001",
    "payUrl": "https://saku.ie/i/team_xyz/tok_…"
  }
}
POST/api/v1/invoices/:id/send

Issue (if still a draft) and email the invoice with PDF attachment. Requires invoices:write. The API key owner must be a team owner or admin. Free teams are limited to 5 sends per calendar month (HTTP 402 when the quota is reached).

Request body (optional)

{
  "recipientEmails": ["[email protected]"]
}

Omit the body or recipientEmails to use the client's primary email.

Response (200)

{
  "invoice": {
    "id": "inv_abc",
    "status": "SENT",
    "number": "INV-2026-0001",
    "sentAt": "2026-07-15T10:05:00.000Z",
    "payUrl": "https://saku.ie/i/team_xyz/tok_…"
  }
}

API access is available on the Max plan Max. Upgrade from pricing and billing. For AI agent integrations, see the MCP server docs.

The brain for your business

Saku is a product of Florian Industries, which is registered with the Companies Registration Office in Ireland (797293). © 2026 Saku.