Developers · Developer API

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..."
Using Raycast?
Follow the Raycast Extension guide for API key setup, command capability behavior, manual QA, and Store release readiness.

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
expensesread, write, delete, payments
categoriesread, write, delete
recipientsread, write, delete
tagsread, write, delete
workspacesread
  • Workspace settings expose read and write key presets.
  • write permission implies read access.
  • write preset grants mutation routes, including expenses.delete and expenses.payments.
  • Older write keys without an explicit expenses.payments entry 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.

GET /api/v1/categories

List categories (workspace + global).

POST /api/v1/categories

Create category. Requires categories.write.

PUT /api/v1/categories/:id

Update category name/description/color.

DELETE /api/v1/categories/:id

Delete category. Requires categories.delete.

Recipients

GET /api/v1/recipients

List recipients (workspace + global).

POST /api/v1/recipients

Create recipient. Requires recipients.write.

PUT /api/v1/recipients/:id

Update recipient name/description.

DELETE /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.

GET /api/v1/tags

List this workspace's tags. Requires tags.read.

POST /api/v1/tags

Create a tag with { name }. Requires tags.write; supports Idempotency-Key.

PUT /api/v1/tags/:tagId

Rename a tag with { name }. Requires tags.write.

DELETE /api/v1/tags/:tagId

Delete a local tag. Requires tags.delete.

Expenses

GET /api/v1/expenses?year=YYYY&month=MM

List expenses for month. year/month query required.

POST /api/v1/expenses

Create expense. Add schedule to create recurring. amount is integer cents (14250 = 142.50).

GET /api/v1/expenses/:expenseId

Get expense by ID. Add ?occurrence=unixSeconds for recurring occurrence view.

PUT /api/v1/expenses/:expenseId

Update expense fields. Use editScope for recurring series.

DELETE /api/v1/expenses/:expenseId

Delete expense. Use ?editScope for recurring. Requires expenses.delete.

POST /api/v1/expenses/:expenseId/skip/toggle

Toggle skip for recurring occurrence (scheduledDate: unix seconds or YYYY-MM-DD).

GET /api/v1/expenses/:expenseId/history

Expense audit history.

GET /api/v1/expenses/monthly-totals

Aggregated monthly totals.

POST /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.

GET /api/v1/expenses/series

List recurring series with cursor, limit, search and status filters. Requires expenses.read.

GET /api/v1/expenses/:expenseId/series

Read series state, revision and history. Requires expenses.read.

POST /api/v1/expenses/:expenseId/series/preview

Preview a lifecycle action with { revision, action }. Requires expenses.write; does not commit the action.

POST /api/v1/expenses/:expenseId/series

Commit with revision, action, previewToken, excludePlanIds and requestId. Requires expenses.write; supports Idempotency-Key.

GET /api/v1/expenses/:expenseId/bill

Read bill state and forecast; optional start/end comparison period. Requires expenses.read.

PUT /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.

POST /api/v1/expenses/:expenseId/variable-bills/preview

Preview switching a fixed monthly series with { from, startAmount }. Requires expenses.read.

POST /api/v1/expenses/:expenseId/variable-bills

Enable variable bills using the preview token and reviewed resolutions. Requires expenses.write; supports Idempotency-Key.

PUT /api/v1/expenses/:expenseId/bill-forecast

Set series-wide { strategy?, startAmount, expectedRevision }; use rootId as expenseId. Requires expenses.write; supports Idempotency-Key.

GET /api/v1/expenses/:expenseId/service-calculation?month=YYYY-MM

Read a service calculation. Requires expenses.read.

POST /api/v1/expenses/:expenseId/service-calculation/preview

Preview { unitPrice, weekdays, fromMonth, expectedRevision }. Requires expenses.read.

PUT /api/v1/expenses/:expenseId/service-calculation

Save future service rates/weekdays with the settings revision. Requires expenses.write; supports Idempotency-Key.

PUT /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, estimated or unknown. Unknown is not zero. forecastPeriod is the target billing period.
  • forecast.amount and currency: the estimate in that currency’s minor units. forecast is null when no estimate is available.
  • forecast.source: the basis actually used, one of invoice, recent_average, seasonal, start_amount or manual; service bills use service_schedule.
  • forecast.assessment: selected strategy, reliability (low, medium, high, manual), reasons, sampleCount, historyCount, seasonalSampleCount and nullable annualTrend (a multiplier). Reliability is a data-quality assessment, not a probability.
  • assessment.fallbackReason: null, no_seasonal_history, insufficient_recent_history or non_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

PUT /api/v1/expenses/:expenseId/payment-status

Set Paid/Unpaid with pre-write context and revisions. Requires expenses.payments.

GET /api/v1/expenses/:expenseId/payments

Read archived payments and current binary totals.

POST /api/v1/expenses/:expenseId/payments

Retired: 410 LEGACY_PAYMENT_OPERATION_REMOVED. Use PUT payment-status.

DELETE /api/v1/expenses/:expenseId/payments

Retired: 410 LEGACY_PAYMENT_OPERATION_REMOVED. Archive is retained.

DELETE /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.

POST /api/v1/insights/range/summary

Read summary for a date range and filters. Requires expenses.read; no mutation or Idempotency-Key.

POST /api/v1/insights/range/comparison

Read comparison for a date range and filters. Requires expenses.read; no mutation or Idempotency-Key.

POST /api/v1/insights/range/recurring

Read recurring for a date range and filters. Requires expenses.read; no mutation or Idempotency-Key.

POST /api/v1/insights/range/expenses

Read expenses for a date range and filters. Requires expenses.read; no mutation or Idempotency-Key.

GET /api/v1/insights/summary

Spending summary for period.

GET /api/v1/insights/comparison

Period-over-period comparison.

GET /api/v1/insights/trends

Spending trends over time.

GET /api/v1/insights/anomalies

Anomaly detection in spending.

GET /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

GET /api/v1/context

Workspace metadata, plan tier, defaults, capabilities. Requires workspaces.read.

GET /api/v1/notifications

List notifications. Requires workspaces.read.

GET /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.baseCurrency is the workspace currency to prefill forms when a request body omits currency.
  • capabilities are derived from the configured API key, the current workspace role and the external route permission matrix.
  • Read-only keys report read capabilities but false for 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
namestringYesExpense name, 1–200 characters.
amountinteger | nullYesMinor units, ≥ 1; 14250 = 142.50 PLN. For variable bills this is the starting estimate; null is allowed except with manual forecasts.
amountModestringNofixed (default) or variable. Ordinary variable bills require a monthly schedule.
forecastStrategystringNoVariable bills only: last_confirmed, recent_average, seasonal or manual. See bill forecasts.
categoryInputstring | object | nullNoOmit 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.
recipientInputstring | UUID | nullNoExisting recipient UUID, new name (auto-created), or null.
descriptionstring | nullNoFree-text note, max 1000 characters.
expenseDateintegerNoUnix timestamp in seconds. Defaults to now.
currencystringNoISO 4217 code (PLN, USD, EUR). Defaults to workspace base currency.
scheduleobject | nullNoInclude to make expense recurring. See Schedule reference.
categoryInput / recipientInput
These fields accept either an existing UUID or a plain-text name. When a name is provided and no matching category/recipient exists, one is created automatically. This simplifies integrations that don't want to pre-create lookup data.

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
namestring1–200 characters.
amountintegerCents (minor units), ≥ 1.
categoryInputstring | object | nullOmit to preserve the category; send null to clear it within the selected edit scope. An existing ID or legal local draft assigns a category.
recipientInputstring | UUID | nullUUID, new name, or null to clear.
descriptionstring | nullMax 1000 characters. null to clear.
expenseDateintegerUnix timestamp in seconds.
occurrenceDateintegerUnix seconds for the selected occurrence only, independently of editScope. Do not combine with expenseDate.
scheduleobject | nullReplace schedule. null converts recurring to one-time.
forecastSettingsobjectSeries-wide variable bill forecasts with ALL_INSTANCES. See fields.
recurringSettingsobjectFixed/variable monthly settings with FUTURE_INSTANCES. See fields and scope.
editScopestringRecurring 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_INSTANCEOnly the specific occurrence. Creates a materialized override.
FUTURE_INSTANCESThis occurrence and all future occurrences.
ALL_INSTANCESEntire 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
yearintegerYesYear (2000–2100).
monthintegerYesMonth (1–12).
includeTemplatesbooleanNoInclude recurring templates. Default: true.
showPaidOnlybooleanNoOnly paid expenses. Default: false.
showUnpaidOnlybooleanNoOnly unpaid expenses. Default: false.
categoryIdUUIDNoFilter by category.
recipientIdUUIDNoFilter by recipient.
limitintegerNoMax 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
startYearintegerYesStarting year (2000–2100).
startMonthintegerYesStarting month (1–12).
monthsintegerNoNumber 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
scheduledDateinteger | stringOccurrence 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 is optional
Without the header, requests are processed normally. The header is recommended for automation integrations where retries are common. Use a fresh UUID per new operation and retain it for retries. Reusing it for a different body or endpoint returns 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
400Validation errorFix request payload or query params. Response body contains field-level errors.
401UnauthorizedCheck API key is valid and not expired.
403ForbiddenKey lacks required permission or route not in allowlist.
404Not foundResource ID does not exist or is not accessible.
409ConflictDuplicate name, plan limit reached, or concurrent idempotent request.
429Rate limitedWait for the Retry-After header before retrying.
5xxServer errorA replay returns the same result. Read the resource to resolve an uncertain outcome; do not blindly submit a new mutation.
Troubleshooting 403 / Cloudflare blocks
Distinguish a JSON permission/plan error from an edge challenge page. Keep the timestamp and request/Ray ID for support when Cloudflare blocks a call. A browser challenge needs an edge configuration fix; changing API keys or blindly retrying will not resolve it.
About timestamps
All date fields (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.

Was this helpful?