Developer API
External integrations authenticate via x-api-key. API keys are workspace-scoped
with granular permission levels per resource. For an agent connection with OAuth, use the
MCP guide. Both work with the same workspace records.
Authentication
Every request must include the x-api-key header with a valid key.
Workspace owners and admins generate keys in workspace settings. Keys are scoped to a single workspace.
Every request also checks the issuer's current membership, role and active account/workspace status.
Revoked, disabled and expired keys cannot authenticate. Store the key securely: it is shown only once.
curl -X GET "https://api.figa.cc/api/v1/expenses?year=2026&month=2" \
-H "x-api-key: et_live_abc123..." Plan limits and downgrade
API keys and MCP workspace bindings share integration slots: Free includes 1 per organization, Pro includes 10 per workspace, and Enterprise has no slot limit. Each key remains scoped to one workspace. An MCP connection uses a slot in each selected workspace. Downgrade retains the oldest eligible integrations; excess connections return plan restrictions. Revocation or expiry releases a slot.
Free exposes the oldest 20 logical expenses in each expense month across the organization.
Selection happens before workspace filters, search, sorting and pagination. Skip and pause retain
places. Deleting an accessible expense reveals the next retained expense. Upgrade restores the
retained pool. Details, payments, history, attachments, statistics and notifications follow the
same rule; direct access to a blocked expense returns 403 EXPENSE_PLAN_RESTRICTED.
Lists and aggregates expose planLimit or planLimits metadata with
current, accessible, blocked, limit and the month.
The quota scope remains organization, while countScope
identifies counts for this workspace (or accessible_workspaces in session-only
cross-workspace statistics). Counts never describe inaccessible workspaces. canCreate
reports whether the shared monthly quota permits another expense, without exposing its usage.
Quota errors omit organization-wide usage counts. Totals describe the accessible subset. Whole-series operations require access to every affected
occurrence. Creates and schedule/date changes that exceed the monthly limit return
409 EXPENSE_LIMIT_REACHED. An old idempotent financial response may return
409 IDEMPOTENCY_RESPONSE_RESTRICTED after downgrade; the committed operation is not repeated.
Organization request budgets are 100 per minute on Free and 1000 on paid plans, shared across
API keys, MCP business tools and workspaces. A single key can use the entire plan budget, including existing keys.
The HTTP protection also caps each verified key at 1000 requests per minute.
Organization admission returns X-Organization-RateLimit-Limit; shared remaining usage
is not disclosed to workspace keys. A rejected request returns 429 and
Retry-After. Rotating keys does not reset the organization budget.
The signed-in account export remains available for the user's complete retained data. API keys cannot access account exports, key management, billing, member administration, internal session routes or the retired Figa AI routes.
Permission model
Each API key has per-resource permissions. Available actions per resource:
| Resource | Actions |
|---|---|
expenses | read, write, delete, payments |
categories | read, write, delete |
recipients | read, write, delete |
tags | read, write, delete |
workspaces | read |
- Workspace settings expose
readandwritekey presets. writepermission impliesreadaccess.writepreset grants mutation routes, includingexpenses.deleteandexpenses.payments.- Older write keys without an explicit
expenses.paymentsentry are still accepted for payment routes for compatibility. - Keys created before tag support need replacement to gain tags permissions; existing keys are not silently expanded.
- Non-allowlisted routes return
403 FORBIDDEN.
Categories
Workspace connections can create and edit local categories; shared organization categories are readable and assignable only. Categories have at most two levels and names are unique among siblings. Creation uses scope: "local"; updates can change parent, with parentId: null making a root. Deletion blocks children and active references, but can permanently clean related archived records. Inspect the affected category before deletion.
/api/v1/categories List categories (workspace + global).
/api/v1/categories Create category. Requires categories.write.
/api/v1/categories/:id Update category name/description/color.
/api/v1/categories/:id Delete category. Requires categories.delete.
Recipients
/api/v1/recipients List recipients (workspace + global).
/api/v1/recipients Create recipient. Requires recipients.write.
/api/v1/recipients/:id Update recipient name/description.
/api/v1/recipients/:id Delete recipient. Requires recipients.delete.
Tags
Assign with tagIds or tagInputs on expense create/update; never send both. Each replaces the entire set, [] clears it, and omission preserves it. Draft inputs can create/reuse tags. For series edits, ALL_INSTANCES updates defaults and inherited stored history; FUTURE_INSTANCES starts at the selected occurrence. Explicit overrides and locked tag sets remain.
Names are trimmed and contain 1–100 characters. Tags always belong to the key's workspace.
/api/v1/tags List this workspace's tags. Requires tags.read.
/api/v1/tags Create a tag with { name }. Requires tags.write; supports Idempotency-Key.
/api/v1/tags/:tagId Rename a tag with { name }. Requires tags.write.
/api/v1/tags/:tagId Delete a local tag. Requires tags.delete.
Expenses
/api/v1/expenses?year=YYYY&month=MM List expenses for month. year/month query required.
/api/v1/expenses Create expense. Add schedule to create recurring. amount is integer cents (14250 = 142.50).
/api/v1/expenses/:expenseId Get expense by ID. Add ?occurrence=unixSeconds for recurring occurrence view.
/api/v1/expenses/:expenseId Update expense fields. Use editScope for recurring series.
/api/v1/expenses/:expenseId Delete expense. Use ?editScope for recurring. Requires expenses.delete.
/api/v1/expenses/:expenseId/skip/toggle Toggle skip for recurring occurrence (scheduledDate: unix seconds or YYYY-MM-DD).
/api/v1/expenses/:expenseId/history Expense audit history.
/api/v1/expenses/monthly-totals Aggregated monthly totals.
/api/v1/expenses/bulk Bulk actions (e.g. markAsPaid). Returns HTTP 207.
Series, bills and service calculations
Lifecycle actions and the dedicated fixed-to-variable conversion use a preview token. Read current revisions before a write; on a stale revision (409), reread and review before retrying. Save amount preserves status. Optional markPaid confirms the amount and sets Paid atomically in that same request.
/api/v1/expenses/series List recurring series with cursor, limit, search and status filters. Requires expenses.read.
/api/v1/expenses/:expenseId/series Read series state, revision and history. Requires expenses.read.
/api/v1/expenses/:expenseId/series/preview Preview a lifecycle action with { revision, action }. Requires expenses.write; does not commit the action.
/api/v1/expenses/:expenseId/series Commit with revision, action, previewToken, excludePlanIds and requestId. Requires expenses.write; supports Idempotency-Key.
/api/v1/expenses/:expenseId/bill Read bill state and forecast; optional start/end comparison period. Requires expenses.read.
/api/v1/expenses/:expenseId/bill Confirm an amount and period with expectedRevision, expectedSettingsRevision and requestId. Service bills also require expectedActivityRevision. Requires expenses.write; supports Idempotency-Key.
/api/v1/expenses/:expenseId/variable-bills/preview Preview switching a fixed monthly series with { from, startAmount }. Requires expenses.read.
/api/v1/expenses/:expenseId/variable-bills Enable variable bills using the preview token and reviewed resolutions. Requires expenses.write; supports Idempotency-Key.
/api/v1/expenses/:expenseId/bill-forecast Set series-wide { strategy?, startAmount, expectedRevision }; use rootId as expenseId. Requires expenses.write; supports Idempotency-Key.
/api/v1/expenses/:expenseId/service-calculation?month=YYYY-MM Read a service calculation. Requires expenses.read.
/api/v1/expenses/:expenseId/service-calculation/preview Preview { unitPrice, weekdays, fromMonth, expectedRevision }. Requires expenses.read.
/api/v1/expenses/:expenseId/service-calculation Save future service rates/weekdays with the settings revision. Requires expenses.write; supports Idempotency-Key.
/api/v1/expenses/:expenseId/service-calculation/exception Set { date, excluded, expectedRevision }. Requires expenses.write; supports Idempotency-Key.
Variable bill forecasts
Ordinary monthly bills support last_confirmed (default), recent_average, seasonal and manual. See how the methods, comparable periods and fallbacks work. Service calculations use separate price/schedule settings.
Create with amountMode: "variable", a monthly schedule and optional forecastStrategy. The required amount field is the starting estimate; send null for no estimate. Manual forecasts require a positive amount. For example, this body creates a seasonal electricity series (replace the category ID):
{
"name": "Electricity",
"amount": 12000,
"currency": "PLN",
"categoryInput": "CATEGORY_ID",
"amountMode": "variable",
"forecastStrategy": "seasonal",
"schedule": { "frequency": "monthly", "startDate": "2026-11-15", "dayOfMonth": 15 }
} Read an occurrence with GET /api/v1/expenses/:expenseId/bill. Its context provides rootId, scheduledDate and settingsRevision; use these returned values for settings edits. The nested bill is also available on expense reads:
state:confirmed,estimatedorunknown. Unknown is not zero.forecastPeriodis the target billing period.forecast.amountandcurrency: the estimate in that currency’s minor units.forecastisnullwhen no estimate is available.forecast.source: the basis actually used, one ofinvoice,recent_average,seasonal,start_amountormanual; service bills useservice_schedule.forecast.assessment: selectedstrategy,reliability(low,medium,high,manual),reasons,sampleCount,historyCount,seasonalSampleCountand nullableannualTrend(a multiplier). Reliability is a data-quality assessment, not a probability.assessment.fallbackReason:null,no_seasonal_history,insufficient_recent_historyornon_monthly_period. The selected strategy can differ from the applied source.
Read bill.state before treating a value as owed. Confirmed amounts stay separate from the current forecast; firstConfirmationForecast retains the estimate at first confirmation. Older saved snapshots can omit assessment.
To change forecasts across all variable periods, PUT /api/v1/expenses/:rootId/bill-forecast with the body below. startAmount is required but nullable, except for manual. Omit strategy to preserve existing methods. Replace revision 3 with the context’s current settingsRevision. This PUT supports Idempotency-Key.
{ "strategy": "seasonal", "startAmount": 12000, "expectedRevision": 3 } For one save with other expense fields, use ordinary expense PUT with this object nested under forecastSettings and editScope: "ALL_INSTANCES". The whole update uses that scope; confirmed invoice amounts stay unchanged.
Change the monthly billing method from an occurrence
PUT /api/v1/expenses/:expenseId with editScope: "FUTURE_INSTANCES" and recurringSettings. Use the selected occurrence ID and its canonical scheduledDate as from (Unix seconds, not a moved due date), or use rootId with that same from. Replace the example timestamp and revision with the current values:
{
"editScope": "FUTURE_INSTANCES",
"recurringSettings": {
"from": 1794697200,
"expectedRevision": 3,
"amountMode": "variable",
"strategy": "seasonal",
"startAmount": 12000
}
} For variable → fixed, use amountMode: "fixed" and amount: 12000 inside recurringSettings, omitting strategy and startAmount. Both directions preserve earlier settings, confirmed invoices and Paid expenses; future planned method changes are replaced from from.
Other editable fields can share this atomic save. Do not combine it with top-level amount, schedule, expenseDate, occurrenceDate or forecastSettings. Only ordinary monthly series support this operation. Schedule edits are unavailable once billing-method history exists. Ordinary expense PUT does not cache Idempotency-Key; after an uncertain outcome, reread before retrying.
Payments
/api/v1/expenses/:expenseId/payment-status Set Paid/Unpaid with pre-write context and revisions. Requires expenses.payments.
/api/v1/expenses/:expenseId/payments Read archived payments and current binary totals.
/api/v1/expenses/:expenseId/payments Retired: 410 LEGACY_PAYMENT_OPERATION_REMOVED. Use PUT payment-status.
/api/v1/expenses/:expenseId/payments Retired: 410 LEGACY_PAYMENT_OPERATION_REMOVED. Archive is retained.
/api/v1/expenses/:expenseId/payments/:paymentId Retired: 410 LEGACY_PAYMENT_OPERATION_REMOVED. Archive is retained.
Insights
Deterministic analytics endpoints: aggregations, trends and forecasts computed from expense data.
All require expenses.read permission. See Insights reference
for full query parameters, response schemas and curl examples.
/api/v1/insights/range/summary Read summary for a date range and filters. Requires expenses.read; no mutation or Idempotency-Key.
/api/v1/insights/range/comparison Read comparison for a date range and filters. Requires expenses.read; no mutation or Idempotency-Key.
/api/v1/insights/range/recurring Read recurring for a date range and filters. Requires expenses.read; no mutation or Idempotency-Key.
/api/v1/insights/range/expenses Read expenses for a date range and filters. Requires expenses.read; no mutation or Idempotency-Key.
/api/v1/insights/summary Spending summary for period.
/api/v1/insights/comparison Period-over-period comparison.
/api/v1/insights/trends Spending trends over time.
/api/v1/insights/anomalies Anomaly detection in spending.
/api/v1/insights/forecast Spending forecast.
Range analytics request
POST range reads accept a range with inclusive ISO dates and optional filters.
Comparison also requires a comparison date range. Expense detail supports
page, pageSize (10, 25 or 50), sort and direction.
{ "range": { "start": "2026-01-01", "end": "2026-01-31" } } Context & Notifications
/api/v1/context Workspace metadata, plan tier, defaults, capabilities. Requires workspaces.read.
/api/v1/notifications List notifications. Requires workspaces.read.
/api/v1/notifications/unread-count Unread notification count.
Context response
GET /api/v1/context returns schemaVersion: 3.
Version 3 adds capabilities.tags and removes the old AI quota fields;
Figa AI is no longer offered. Clients that pin v2 must update their context parser.
defaults and capabilities describe available workflows.
defaults.baseCurrencyis the workspace currency to prefill forms when a request body omitscurrency.capabilitiesare derived from the configured API key, the current workspace role and the external route permission matrix.- Read-only keys report read capabilities but
falsefor write, delete, and payment mutations. - Write keys report mutation capabilities, including payment routes currently accepted through the write fallback.
- The response uses Cache-Control: private, no-store. Refresh it after plan or permission changes.
{
"success": true,
"message": "Context retrieved",
"data": {
"schemaVersion": 3,
"generatedAt": 1770000000,
"workspace": {
"id": "workspace-uuid",
"name": "Acme Finance",
"baseCurrency": "PLN"
},
"defaults": {
"baseCurrency": "PLN"
},
"capabilities": {
"expenses": {
"read": true,
"write": false,
"delete": false,
"payments": false
},
"categories": {
"read": true,
"write": false,
"delete": false
},
"recipients": {
"read": true,
"write": false,
"delete": false
},
"tags": { "read": true, "write": false, "delete": false },
"workspaces": {
"read": true
}
},
"plan": {
"tier": "pro",
"criticalLimits": {
"apiKeysPerWorkspace": 10,
"apiKeysPerOrganization": null,
"maxApiRequestsPerMinute": 1000,
"maxExpensesPerMonth": null
}
}
}
} Create expense: request body
POST /api/v1/expenses · Permission: expenses.write
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Expense name, 1–200 characters. |
amount | integer | null | Yes | Minor units, ≥ 1; 14250 = 142.50 PLN. For variable bills this is the starting estimate; null is allowed except with manual forecasts. |
amountMode | string | No | fixed (default) or variable. Ordinary variable bills require a monthly schedule. |
forecastStrategy | string | No | Variable bills only: last_confirmed, recent_average, seasonal or manual. See bill forecasts. |
categoryInput | string | object | null | No | Omit or send null for no category. Existing category ID, or a local name/draft with name and parentId/parentName. Drafts create/reuse siblings; prefer IDs for reassignment. |
recipientInput | string | UUID | null | No | Existing recipient UUID, new name (auto-created), or null. |
description | string | null | No | Free-text note, max 1000 characters. |
expenseDate | integer | No | Unix timestamp in seconds. Defaults to now. |
currency | string | No | ISO 4217 code (PLN, USD, EUR). Defaults to workspace base currency. |
schedule | object | null | No | Include to make expense recurring. See Schedule reference. |
Minimal one-time expense
curl -X POST https://api.figa.cc/api/v1/expenses \
-H "x-api-key: et_live_abc123..." \
-H "Content-Type: application/json" \
-d '{
"name": "AWS June invoice",
"amount": 14250
}' This minimal create has no category. Sending categoryInput: null has the same result. Invalid or inaccessible category IDs and empty category drafts remain validation errors.
Full one-time expense
curl -X POST https://api.figa.cc/api/v1/expenses \
-H "x-api-key: et_live_abc123..." \
-H "Content-Type: application/json" \
-d '{
"name": "Office rent February",
"amount": 420000,
"categoryInput": "Housing",
"recipientInput": "WeWork",
"description": "Monthly office rent, invoice #2026-02",
"expenseDate": 1739577600,
"currency": "PLN"
}' Recurring expense (monthly rent)
curl -X POST https://api.figa.cc/api/v1/expenses \
-H "x-api-key: et_live_abc123..." \
-H "Content-Type: application/json" \
-d '{
"name": "Office rent",
"amount": 420000,
"categoryInput": "Housing",
"recipientInput": "WeWork",
"schedule": {
"frequency": "monthly",
"startDate": "2026-01-01",
"dayOfMonth": 1
}
}' For all schedule variants (weekly, quarterly, yearly, limited installments, etc.) see the Recurring schedule reference.
Confirm amount and optionally mark Paid
PUT /api/v1/expenses/:expenseId/bill requires expenses.write. Send the actual amount in original-currency minor units, inclusive period, a UUID requestId, expectedRevision and expectedSettingsRevision. Services also require expectedActivityRevision.
Omit markPaid (or send false) to preserve the current payment status. With markPaid: true, send expectedEditContext and expectedPaymentStatusRevision from expense detail. Confirmation and Paid share one transaction; all expectations refer to the state before the request. Failure leaves neither a saved amount nor a new occurrence. This operation creates no payment-history row.
For example, confirming an unknown January bill with amount: 10000, its January period, markPaid: true and current context/revisions saves a confirmed Paid bill for 100.00. Correcting an already Paid bill to 80.00 preserves its known or unknown Paid date. Optional paymentDate uses Unix seconds and applies only when changing Unpaid to Paid; omission uses the server date for that transition.
Retry with the same ID and unchanged payload, including intent and supplied date or its absence. Different input under a retained ID returns 409. Ordinary bills retain only their last request receipt; service receipts retain their existing durable scope. Old ordinary receipts without a verified fingerprint fail closed. Once no receipt remains, stale expectations return 409. A matching replay may return an earlier response without writing: it never restores Paid after a later Unpaid. Always read the current expense after confirmation or replay.
Update expense: request body
PUT /api/v1/expenses/:expenseId · Permission: expenses.write
Partial update: include only the fields you want to change.
Expense detail GET returns an opaque editContext. Keep it with the draft and send it unchanged as optional expectedEditContext on PUT, including range and legacy start-only edits. If persisted expense or series state changed, the API returns 409 EXPENSE_WRITE_CONFLICT without saving anything or returning a replacement token. Preserve the draft; do not automatically load a new token and retry the old changes. Discard the draft and read the current version before creating a new intended edit. Legacy requests without the condition remain accepted but have no stale-form protection. Existing seriesContext, bill/settings revisions and previewToken requirements still apply. For an uncertain range response, resend the exact original body/requestId: a retained receipt replays without another mutation, even with the original edit context.
| Field | Type | Description |
|---|---|---|
name | string | 1–200 characters. |
amount | integer | Cents (minor units), ≥ 1. |
categoryInput | string | object | null | Omit to preserve the category; send null to clear it within the selected edit scope. An existing ID or legal local draft assigns a category. |
recipientInput | string | UUID | null | UUID, new name, or null to clear. |
description | string | null | Max 1000 characters. null to clear. |
expenseDate | integer | Unix timestamp in seconds. |
occurrenceDate | integer | Unix seconds for the selected occurrence only, independently of editScope. Do not combine with expenseDate. |
schedule | object | null | Replace schedule. null converts recurring to one-time. |
forecastSettings | object | Series-wide variable bill forecasts with ALL_INSTANCES. See fields. |
recurringSettings | object | Fixed/variable monthly settings with FUTURE_INSTANCES. See fields and scope. |
editScope | string | Recurring only. See below. |
Add earlier recurring history
Send range: { start } to the same PUT, with the seriesContext from a fresh expense detail and a new UUID requestId. The start accepts a valid YYYY-MM-DD or integer Unix seconds within reporting years 1970–2099 in the series timezone. Monthly lists and totals support reading the added history from 1970. It is normalized to the first term on or after that boundary using the existing recurrence rule. It applies to the entire family, independently of editScope. The current release supports daily, weekly, monthly, quarterly and yearly families, including interval and weekday patterns, with date, count or no end. Lifecycle history retains its restrictions on active count. Both backfill and lower-boundary changes support dated method history, with the inheritance and protection rules below. Variable amounts keep their existing monthly-only configuration. The original calendar and date mode must survive normalization; an unprovable backfill returns SERIES_RULE_NOT_PRESERVED without saving.
For example, changing 3 January 2026 to 3 January 2025 adds the 2025 terms. Existing confirmed amounts, payments, bill periods, manual dates, skipped and deleted occurrences stay unchanged. New fixed terms are unpaid; unknown variable terms are incomplete until explicitly confirmed. No payment or bill is recorded automatically.
{
"range": { "start": "2025-01-03" },
"seriesContext": "series-v1:...",
"requestId": "5dcbfc0a-c745-4e9d-81e0-3af6dc8c09b4"
} range may share one atomic PUT with occurrenceDate, metadata and tags, alongside seriesContext, requestId and an unchanged raw schedule. The range applies to the family and occurrenceDate to the selected occurrence; editScope applies to the other fields. FUTURE uses the original scheduled date, even if the expense date moves. A retained stored document may be edited before the new start. An explicit edit of an occurrence that disappears or is excluded rejects the whole save; ALL/FUTURE metadata may still use its original boundary without materializing it. forecastSettings still requires editScope: ALL_INSTANCES. Rhythm, method and whole-period amount changes give 400 SERIES_COMPOSITION_UNSUPPORTED. A fixed occurrence amount may be edited with explicit THIS_INSTANCE, subject to the existing money guards. Combining occurrenceDate with legacy expenseDate is invalid. Failure rolls back every part, including new categories/tags; retries cover the complete PUT. The editable end can be changed as described below. Extending before the anchor also supports dated method history: the prefix inherits the first period’s fixed amount or variable strategy/start amount, while later boundaries stay unchanged. For example, an explicit first period of 180 USD overrides a legacy default of 250 USD. A retained document whose billing settings would change causes 409 SERIES_HISTORY_CONFLICT, even when its amount is equal. Actual method-history or variableFrom changes advance bill settings revision once; lower-boundary edits alone do not. Refetch before a new confirmation after a revision conflict; completed identical bill retries remain idempotent. Fixed historical amounts enter forecasts only after explicit historical confirmation, including after a verified payment; different original currencies, skips and deleted sources remain excluded. An unsupported calendar gives SERIES_RANGE_UNSUPPORTED; services require their own bill operation. Missing request ID is 400 SERIES_REQUEST_ID_REQUIRED; missing/stale context is 409 SERIES_CONTEXT_REQUIRED or SERIES_CONTEXT_CONFLICT. Calendar preview proves the dates, not all write-time admission checks. Existing plan limits and the separate financial projection work limit can still refuse the save with no partial mutation.
A combined range and forecast save first inherits the prefix settings, then applies forecast ALL to every resulting variable period and the root. Fixed amounts stay unchanged. Omit strategy to keep each period’s strategy; a null start amount is invalid while the root or any remaining variable period is manual. Both expected revisions refer to the state before the save. The whole operation advances bill settings revision once, including an explicit forecast no-op, and returns one range receipt. An unchanged range does not advance range revision. Failure rolls back both parts and their required audits; confirmed amounts and first-confirmation forecasts remain unchanged.
Legacy compatibility change: replacing only schedule.startDate now changes the series range, preserving recorded occurrence dates and money. Send the complete raw schedule, explicit editScope: ALL_INSTANCES, and top-level seriesContext/requestId. Read the expense first; its raw schedule belongs to scheduleSourceTemplateId. This adapter accepts only the earliest segment. A child start edit returns SERIES_RANGE_TARGET_REQUIRED; use range from that same occurrence. Omitted/THIS/FUTURE scope returns SERIES_RANGE_SCOPE_REQUIRED. Older clients without context/requestId receive an explicit error; the API never supplies those values automatically.
An unchanged raw schedule is a no-op, including when the visible start is later. Raw January plus a June lower boundary and a legacy March start moves the boundary to March, leaving the January anchor. For a January/March/May rhythm, a February start normalizes to March. Preserve every raw end field: omitting one changes the end, and start+end must use range. Actual rhythm or timezone changes use the existing guarded rebase; changing the start representation between ISO and Unix returns SERIES_DATE_MODE_CHANGE_UNSUPPORTED. The direct template endpoint refuses start-only writes. Legacy expenseDate ALL/FUTURE edits keep their existing scope and money protections.
After reading the expense, send e.g. { schedule: { ...expense.schedule, startDate: "2025-01-03" }, editScope: "ALL_INSTANCES", seriesContext: expense.seriesContext, requestId: newUuid } when its raw start is ISO. Use Unix seconds for a numeric raw start. On a context conflict, read again and submit the reviewed change with a new request ID. On an uncertain response, resend the identical original body and request ID; do not replace its context. Success returns data.rangeOperation, including on replay after the selected virtual term disappears. A changed body with the same ID returns SERIES_REQUEST_ID_CONFLICT. The optional Idempotency-Key must equal the body request ID.
Moving forward stores a lower generation boundary, without rewriting the raw schedule.startDate, amounts or method history. Sending the original start in range removes that filter; resending an unchanged raw schedule does not. Details expose the whole family’s effectiveStartDate and effectiveStartDateMode (iso or unix), independent of the opened segment. Use that mode to display the returned start; the selected segment may use a different date representation. Saved documents before the boundary remain available for permitted confirmation, payment, editing and deletion. Earlier virtual IDs cannot create new documents. If the selected virtual term leaves the range, the first success returns the root as data.expense and selection.kind=series with its resultExpenseId; a replay still succeeds without recreating the term.
For a permanently ended series, details expose permanentEndDate (YYYY-MM-DD in the series root timezone) separately from the editable effectiveEnd. The App displays that permanent end as read-only and omits range.end when changing the start. Start-only edits preserve pauses, lifecycle exclusions and the permanent end whether it is in the past or future. The first nominal term must remain on or before that end; no new term can generate or materialize after it. Every explicit end, even the same date or a shorter end, returns EXPENSE_LIFECYCLE_CONFLICT. An explicit count end is refused while an uncanceled pause remains, preserving the ability to resume or cancel that pause. Active count together with a permanent end also refuses the edit; date/none family overlays deactivate raw count limiters. A naturally expired schedule without endedAfter is still editable. Services retain BILL_OPERATION_REQUIRED.
While a lower boundary or a lower-plan exclusion is active, legacy FUTURE deletion and conversion return 409 EXPENSE_LIFECYCLE_CONFLICT before changing any document. Use the series end action where supported; count-based series can change their editable end through the range operation. Changing the end preserves recorded documents and is not bulk deletion. Individual deletion and permitted whole-series deletion retain their own guards. Legacy yearly projections return BILL_READ_UNSUPPORTED; use canonical unified statistics.
An optional Idempotency-Key header must equal the body UUID; it does not replace it. On an uncertain response, resend exactly the same target and payload with the same ID. The durable receipt is checked before the old context. Reusing the ID for different input returns 409 SERIES_REQUEST_ID_CONFLICT. Current workspace permission and plan access are checked on every replay.
For a supported family with multiple versions, range.end date/count/none applies to the whole family without deleting versions or saved documents. Omission preserves the current end and its ownership. The first explicit end stores a root-owned generationEnd; null keeps each legacy segment’s limits, whereas explicit none removes their effect. Later metadata or rhythm edits retain that family end. Activation that would fill an existing interior calendar gap fails with SERIES_HISTORY_CONFLICT. A changed raw end then fails with SERIES_COMPOSITION_UNSUPPORTED; use range.end. Destructive FUTURE deletion/conversion fails with EXPENSE_LIFECYCLE_CONFLICT, including with explicit none. A family count stores nominal positions across all segments from the physical start, before the lower boundary, skips and deleted documents. Its effective count starts at the normalized lower boundary. This remains true if ALL later collapses the versions; no separate count is copied into each child.
For a series with a count end, effectiveOccurrenceCount is the raw count minus nominal terms before the lower boundary. Skips and deleted documents still consume nominal positions; this is not commitment.totalOccurrences, which counts active charges. For a supported series, send range: { end: { type: "count", value: 10 } } to set the effective count. You may combine it with range.start; the count then starts at the final normalized first term. The App uses the same operation for its occurrence-count control.
Example: raw start 3 January 2026 and count 12 end on 3 December 2026. Moving the start to 3 June shows 7 terms and retains raw 12. Entering 10 effective terms stores raw 15 and ends on 3 March 2027. Starting again from the original raw 12, moving the start back to January 2025 without an end stores raw 24 and retains December 2026: omitted end preserves the last date, not the visible number. Raw schedule fields remain raw; an unchanged raw count is a no-op.
Family example: monthly terms fall on the 3rd from January through May, then on the 8th from June. A count of 8 ends on 8 August. Moving the start to 1 June normalizes it to 8 June, shows 3 remaining terms, and keeps the August end. Entering 5 then stores raw 10 and ends on 8 October. Omitted end preserves that last date when backfilling. Active count does not support pause/end lifecycle actions; edit the range end instead.
The same range accepts end: { type: "date", value: "2026-08-15" }, end: { type: "count", value: 4 }, or end: { type: "none" }. A date includes that entire calendar day but never creates an off-rhythm term: a series due on the 3rd and ending on 15 August has its last term on 3 August. Date input uses the same reporting bounds as start. A count begins at the final normalized start; none removes both editable limiters. Omitted end preserves the existing end type and last scheduled date. The App exposes these same end controls for supported series, including those with dated method history; its rhythm restrictions remain unchanged.
Details expose backend-resolved effectiveEnd as date (the selected raw ISO date or Unix seconds, preserving its calendar mode), count (effective nominal count), or none. Existing raw endDate and endAfter date forms keep their original representation on reads and start-only changes. Older stored calendars with both limiters use the earlier restriction; reads never rewrite them. An unchanged raw PUT remains a no-op. New or genuinely changed raw schedules require one limiter; explicit range.end for a single version without a family overlay stores one canonical end (date as endDate, count as endAfter, none removes both). A family overlay retains the raw segment fields and replaces their effect instead. A changed raw end combined with range gives 400 SERIES_COMPOSITION_UNSUPPORTED. A permanent lifecycle end cannot be changed by this operation and returns 409 EXPENSE_LIFECYCLE_CONFLICT.
The stored count must remain within 1–1000 after translation. Effective 10 plus 995 earlier nominal terms would require raw 1005, so preview and commit refuse with 400 SERIES_RANGE_COUNT_LIMIT and explain the calculation. A start after the last term gives 400 SERIES_RANGE_EMPTY. Neither refusal saves anything. The 2100 calendar-proof boundary does not truncate nominal counts whose last term is later; explicit input dates still obey the reporting-date limits. Existing raw anchors before 1970 also count in the nominal prefix; effectiveStartDate may report such an inherited date without allowing a new start outside the input limits.
The first success returns data.expense and data.rangeOperation. The latter contains requestId, rootId, committedContext, outcome (applied or no_change), and selection with requestedExpenseId, resultExpenseId and kind (occurrence or series). A replay may return only rangeOperation. Refetch resultExpenseId for current data; the receipt is evidence of the completed operation, not a fresh expense snapshot.
Receipts live with the recurring family, independently of audit retention and transport caches. Soft deletion retains them; permanent family/workspace/personal-account deletion removes them. MCP figa_update_expense accepts the same range contract and copies its outer requestId into the shared operation. If changes.requestId is supplied, it must match. Durable receipt replay remains available after the 24-hour MCP response cache expires, subject to current access and plan limits. The deprecated template, instance, materialization and bulk APIs do not accept these fields.
The App sidebar obtains its summary from the read-only session endpoint POST /api/v1/workspaces/:workspaceId/expenses-unified/:expenseId/range-preview, with { range, seriesContext } and optional forecastSettings plus editScope: ALL_INSTANCES. Combined previews validate the same forecast rules and show the resulting variable-prefix settings. Its data.rangePreview contains the normalized effectiveStartDate and its mode, extendsHistory (whether the operation adds a prefix), effectiveEnd, effectiveOccurrenceCount, inheritedSettings, excludedPlanCandidates with each document’s UUID, scheduled date and its scheduledDateMode, expense date, protection and selected state, and complete: true only after a complete proof. It creates no documents or receipt and does not reserve the write; PUT revalidates. This is separate from a lifecycle previewToken.
range.excludePlanIds optionally selects recorded plans before the resulting first term. Omit it to preserve existing lower-boundary markings, send a UUID array to replace only those markings, or send [] to clear them. It may be the only field in range. Nothing is selected automatically. Confirmed invoices and Paid expenses cannot be excluded; archived payments alone do not protect an Unpaid plan: commit returns 409 SERIES_PLAN_PROTECTED without a partial save. This includes reactivating an old marking after the restored expense was paid or confirmed; remove that marking explicitly. The preview shows such a retained selection as protected so it can be removed.
For example, moving the first term from January to June preserves a recorded January plan unless its UUID is explicitly selected. Moving the first term back to January restores that same document, with its attachments and manual expense date. Skips and deletion remain in force. Excluded documents cannot be edited, paid or materialized through old IDs. These markings do not change pause/end history.
The exclusion preview reads at most 500 recorded documents in the whole family, not 500 selections. A 501st document returns 409 VIRTUAL_INSTANCE_GENERATION_LIMIT_EXCEEDED; no partial list is presented as complete. PUT repeats completeness and protection checks under locks. Splitting the selection into smaller requests does not bypass the document limit.
editScope: recurring expense edits
For example, editing November’s occurrence with occurrenceDate set to 8 December moves only that document. Its canonical scheduledDate, invoice period, payments and financial snapshots remain unchanged; December’s regular occurrence stays separate. Other fields in the same request retain their selected scope. Dates must fit both the generator domain and reporting years 1970–2099 in the stored series timezone. Conflicting date fields or a financial guard failure roll back the entire update. Legacy expenseDate retains its THIS/FUTURE/ALL behavior. The new field is available through this API and MCP figa_update_expense in changes.occurrenceDate.
currency and isPaid are not expense PUT fields. Keep the original currency and use payment operations to record money paid.
When updating or deleting a recurring expense, editScope controls which occurrences are affected:
| Value | Effect |
|---|---|
THIS_INSTANCE | Only the specific occurrence. Creates a materialized override. |
FUTURE_INSTANCES | This occurrence and all future occurrences. |
ALL_INSTANCES | Entire series (past + future). Updates the template directly. |
Update single occurrence of a recurring expense
curl -X PUT https://api.figa.cc/api/v1/expenses/{expenseId} \
-H "x-api-key: et_live_abc123..." \
-H "Content-Type: application/json" \
-d '{
"amount": 450000,
"editScope": "THIS_INSTANCE"
}' Update all future occurrences
curl -X PUT https://api.figa.cc/api/v1/expenses/{expenseId} \
-H "x-api-key: et_live_abc123..." \
-H "Content-Type: application/json" \
-d '{
"amount": 450000,
"editScope": "FUTURE_INSTANCES"
}' List expenses: query parameters
GET /api/v1/expenses · Permission: expenses.read
| Param | Type | Required | Description |
|---|---|---|---|
year | integer | Yes | Year (2000–2100). |
month | integer | Yes | Month (1–12). |
includeTemplates | boolean | No | Include recurring templates. Default: true. |
showPaidOnly | boolean | No | Only paid expenses. Default: false. |
showUnpaidOnly | boolean | No | Only unpaid expenses. Default: false. |
categoryId | UUID | No | Filter by category. |
recipientId | UUID | No | Filter by recipient. |
limit | integer | No | Max results (1–50). |
List unpaid expenses for February 2026
curl -X GET "https://api.figa.cc/api/v1/expenses?year=2026&month=2&showUnpaidOnly=true" \
-H "x-api-key: et_live_abc123..." Delete expense
DELETE /api/v1/expenses/:expenseId · Permission: expenses.delete
For recurring expenses, pass editScope as a query parameter to control what gets deleted:
# Delete single occurrence
curl -X DELETE "https://api.figa.cc/api/v1/expenses/{expenseId}?editScope=THIS_INSTANCE" \
-H "x-api-key: et_live_abc123..."
# Delete entire recurring series
curl -X DELETE "https://api.figa.cc/api/v1/expenses/{expenseId}?editScope=ALL_INSTANCES" \
-H "x-api-key: et_live_abc123..." Set Paid or Unpaid
PUT /api/v1/expenses/:expenseId/payment-status · Permission: expenses.payments
Read the expense detail first. Send the desired isPaid, its editContext as expectedEditContext, and paymentStatusRevision as expectedPaymentStatusRevision. A confirmed bill also requires expectedBillRevision. Copy the returned context unchanged; never construct it or silently bind an old command to a fresh read.
{
"isPaid": true,
"expectedEditContext": "COPY_FROM_EXPENSE_DETAIL",
"expectedPaymentStatusRevision": 3,
"expectedBillRevision": 2,
"paymentDate": 1739577600
} The example revisions are placeholders. Omit expectedBillRevision for an ordinary non-bill expense. Optional paymentDate uses Unix seconds and defaults to now only on a real Unpaid → Paid transition. Repeating Paid with fresh expectations preserves its known or unknown date; Unpaid clears the current date. Only a real transition advances the status revision.
No amount, paymentAmount, method or notes are accepted, and no payment-history row is created or removed. Unknown, estimated or skipped bills cannot become Paid before legal confirmation/restoration. Use atomic confirmation with markPaid to save an actual amount and set Paid together. A later correction from 100 to 80 or 120 preserves status and date; it does not edit the archive.
Keep a UUID Idempotency-Key and the whole payload unchanged for an uncertain retry within the existing 24-hour cache. A conflict requires a fresh read and a reviewed new intent. Replay may return an earlier receipt; GET the expense for current status before reporting success. After a later Unpaid, replay cannot restore Paid, including after cache expiry.
Old POST/DELETE payment mutations and legacy amount writers return 410 LEGACY_PAYMENT_OPERATION_REMOVED, with the replacement operation. A stale App card must refresh; it cannot report an old payment as success.
Read archived payments and current totals
GET /api/v1/expenses/:expenseId/payments · Permission: expenses.read
The archive remains readable and exportable. Each row keeps its currency-labelled amount, base snapshot and currencySemantics. Current totalPaid/remainingAmount use the expense’s original currency and its binary status: the whole known amount belongs to one side. The base pair uses the current stored base valuation. Unknown valuations stay null. Archived rows do not create a current partial balance.
For example, an Unpaid 100 USD expense valued at 410 PLN with a historical 25 USD payment returns:
{
"payments": [{
"amount": 2500,
"currency": "USD",
"baseAmountMinor": 10250,
"baseCurrency": "PLN",
"currencySemantics": "explicit"
}],
"metadata": {
"totalPaid": 0,
"remainingAmount": 10000,
"currency": "USD",
"baseTotalPaid": 0,
"baseRemainingAmount": 41000,
"baseCurrency": "PLN",
"archivedTotalPaid": 2500,
"archivedCurrency": "USD",
"currencySemantics": "explicit",
"paymentCount": 1
}
} Missing historical base values do not invalidate a complete current expense valuation. currencySemantics describes the archive; legacy ambiguous rows retain their labelled raw value. When archived row currencies differ, archivedTotalPaid and archivedCurrency are null. Never add different currencies or fill missing historical FX with today’s rate.
Monthly totals: query parameters
GET /api/v1/expenses/monthly-totals · Permission: expenses.read
| Param | Type | Required | Description |
|---|---|---|---|
startYear | integer | Yes | Starting year (2000–2100). |
startMonth | integer | Yes | Starting month (1–12). |
months | integer | No | Number of months to return (1–12). Default: 7. |
curl -X GET "https://api.figa.cc/api/v1/expenses/monthly-totals?startYear=2026&startMonth=1&months=6" \
-H "x-api-key: et_live_abc123..." Bulk actions
POST /api/v1/expenses/bulk · Permission: expenses.write · HTTP 207 with per-item results.
Supported actions are markAsPaid, markAsUnpaid and delete, with 1–100 IDs. Status actions require expectations[id] copied from each reviewed expense detail, including the confirmed bill revision where applicable. Missing/stale expectations fail that item; successful items remain committed. A fresh no-op is skipped. Unpaid preserves archived payments.
{
"action": "markAsPaid",
"ids": ["EXPENSE_ID"],
"expectations": {
"EXPENSE_ID": {
"expectedEditContext": "COPY_FROM_EXPENSE_DETAIL",
"expectedPaymentStatusRevision": 0
}
},
"payload": { "paymentDateMode": "expenseDate" }
} paymentDateMode: "expenseDate" selects each current expense date under locks. Alternatively send one paymentDate, or omit both to use now. Combining both is invalid. paymentMethod belongs to the retired payment model and is refused. This API contract does not enable a disabled bulk feature in the App.
Skip / restore occurrence
POST /api/v1/expenses/:expenseId/skip/toggle · Permission: expenses.write
Toggle the skip status of a single recurring occurrence. Only unpaid instances can be skipped. Skipped instances remain visible but are excluded from totals.
| Field | Type | Description |
|---|---|---|
scheduledDate | integer | string | Occurrence identifier: Unix timestamp (seconds) or ISO date YYYY-MM-DD. |
Skip using Unix timestamp
curl -X POST https://api.figa.cc/api/v1/expenses/{expenseId}/skip/toggle \
-H "x-api-key: et_live_abc123..." \
-H "Content-Type: application/json" \
-d '{"scheduledDate": 1740787200}' Skip using ISO date (recommended for automations)
curl -X POST https://api.figa.cc/api/v1/expenses/{expenseId}/skip/toggle \
-H "x-api-key: et_live_abc123..." \
-H "Content-Type: application/json" \
-d '{"scheduledDate": "2026-03-30"}'
If no matching occurrence exists, API returns 404 (for both timestamp and ISO date).
Idempotency
POST mutations support the Idempotency-Key header (UUID format).
If the same key is sent again within 24 hours, the server returns the cached response
instead of processing the request again, including a completed 5xx response.
Bill confirmation/forecast and service-calculation PUTs also support this header.
Other existing PUT/DELETE routes do not cache a supplied idempotency key.
curl -X POST https://api.figa.cc/api/v1/expenses \
-H "x-api-key: et_live_abc123..." \
-H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \
-H "Content-Type: application/json" \
-d '{"name":"AWS","amount":14250,"categoryInput":"Cloud"}' IDEMPOTENCY_KEY_MISMATCH (409).
Pending duplicates return IDEMPOTENT_REQUEST_IN_PROGRESS (409).
An interrupted request may have committed: inspect the resource after an uncertain result,
including IDEMPOTENCY_OUTCOME_UNKNOWN, before sending another mutation with a new key.
Replay protection has a 24-hour lifetime; it is not an unlimited exactly-once guarantee.
Unavailable to workspace API keys
Account/security settings, whole-account exports, organization/workspace management, membership, invitations, billing, key management, admin/console/Pulse, UI locks and global category/recipient mutations are not authorized by a workspace API key. Figa AI is not an API capability. Attachment transfer and CSV import are not exposed through this API; structured expense creation and bulk actions are available. Notification reads are workspace-scoped; personal notification settings and read/dismiss actions remain app-only.
Error handling
| Status | Meaning | Action |
|---|---|---|
400 | Validation error | Fix request payload or query params. Response body contains field-level errors. |
401 | Unauthorized | Check API key is valid and not expired. |
403 | Forbidden | Key lacks required permission or route not in allowlist. |
404 | Not found | Resource ID does not exist or is not accessible. |
409 | Conflict | Duplicate name, plan limit reached, or concurrent idempotent request. |
429 | Rate limited | Wait for the Retry-After header before retrying. |
5xx | Server error | A replay returns the same result. Read the resource to resolve an uncertain outcome; do not blindly submit a new mutation. |
expenseDate, paymentDate, scheduledDate,
startDate as integer) are Unix timestamps in seconds (not milliseconds).
String dates accept ISO 8601 format (YYYY-MM-DD).
The App’s CSV importer accepts a missing category column or an empty cell as no category, whether category auto-creation is on or off. A nonempty unknown name is created only when auto-creation is enabled; otherwise that row fails. CSV still supports one-time expenses and fixed recurring templates, not variable bills or services. Account exports preserve categoryId: null in JSON and an empty categoryId cell in CSV. The account archive is a historical export, not the CSV importer’s input format.