Overview
Base URL, authentication, household scoping, pagination, and errors.
The Penny API gives scripts and integrations access to household financial data. Connect bank accounts and sync Apple financial accounts through the Penny app.
Download OpenAPI schema for this reference. You can import it into API tools or use it to generate a client.
Base URL
https://api.penny.financeEvery path is versioned under /v1.
Authentication
Use a personal API token to authenticate scripts and integrations. In the app, open Settings → API Tokens and create a token. Give it a name, choose its access level and expiration, then copy the token.
The full token is shown only once. Save it somewhere secure, such as a password manager, before closing the screen.
Send the token in the Authorization header on each request:
Authorization: Bearer penny_pat_YOUR_TOKENFor example, with your token stored in the PENNY_API_TOKEN environment variable:
curl https://api.penny.finance/v1/households \
-H "Authorization: Bearer $PENNY_API_TOKEN"Token permissions
- Read only (
read) permits read requests. Writes return403 read_only_token. - Read and write (
read_write) permits actions your account is allowed to perform.
A token never has more access than you do. A household viewer stays read-only even with a read/write token. If your role changes or you are removed from a household, existing tokens follow your new permissions on subsequent requests. Household subscription requirements also apply.
Expiry and revocation
The app defaults to a 90-day expiration. You can choose 30 days, 90 days, 1 year, or Never when creating a token. Existing tokens keep their original expiration dates.
To revoke a token, open Settings → API Tokens, select it, and confirm
Revoke. Expired or revoked tokens return 401 not_authenticated; create a
replacement to continue using the API. Changing or resetting your password also
invalidates existing tokens.
To rotate a token, create a replacement, update your integration to use it, then revoke the old token.
Treat tokens like passwords: keep them out of source control, shared scripts, and application logs. Use a read-only token when your integration only needs to retrieve data.
Household scope
Most financial data belongs to a household, whose ID is included in the path:
/v1/households/{household_id}/transactionsCall GET /v1/households first to find the IDs you have access to. What you can do
inside one depends on your role: viewers get read-only, and owner-only routes
return 403 for everyone else.
IDs
Resource IDs are opaque strings. Don't parse them, and don't assume ordering or length.
Pagination
Paginated list endpoints return a page of results and a cursor for the next page:
{
"transactions": [],
"next_cursor": "eyJkIjoiMjAyNi0wNy0xNCIsImkiOiJ0eG5fOWYz...",
"has_more": true
}Transaction objects are omitted here to highlight the pagination fields.
Pass next_cursor back as the cursor query parameter for the next page, and stop
when has_more is false. Use min_limit to hint at page size.
Treat cursors as opaque strings. Keep the same filters while paging; if you change
a filter, start again without a cursor. Transaction pages keep complete days
together, so a page can contain more than min_limit transactions.
Errors
Failures return a JSON body with a stable machine-readable code and a message
meant for humans:
{ "code": "not_authenticated", "message": "Authentication required" }Use code for error handling. Message wording and status text may change.
| Status | Meaning |
|---|---|
400 | The request was malformed or the operation isn't valid in this state |
401 | Missing, expired, revoked, or invalid token |
402 | The household requires an active subscription |
403 | The token's access level or your current household membership or role doesn't allow it |
404 | No such resource, or it isn't in a household you can see |
415 | A JSON request body was sent without Content-Type: application/json |
422 | Validation failed. The body carries an errors array describing each field error |
500 | Internal server error |
A 422 looks like this:
{
"code": "validation_error",
"message": "Some fields are invalid.",
"errors": [
{
"field": "amount",
"code": "decimal_parsing",
"message": "Input should be a valid decimal",
"context": null
}
]
}For validation_error, read errors[] to handle each failure. Each entry has a
field containing the public field path (for example, amount or
splits.0.amount), a machine-readable code, and a human-readable message.
Field paths omit the request source (body, query, or path). The context
field contains additional validator details as a string-valued object, or null
when there are none.
Money and dates
Monetary amounts use decimal strings to avoid binary floating-point rounding. Each amount has a currency, which can differ from the household currency. For details, see multiple currencies.
Dates are YYYY-MM-DD. Timestamps are ISO 8601 in UTC.
Expenses are negative and income is positive, on every account type. More on signs →
Trying it out
Each endpoint page has a playground. Select PersonalApiToken and paste the
token itself, without Bearer; the playground adds that prefix to the
Authorization header. Send issues a real production request against your
data. Write requests change your data; the playground has no sandbox.
Compatibility
We maintain backward compatibility for the public /v1 API. Existing paths,
field names, field types, and error codes remain compatible as the API evolves.
Clients should tolerate new endpoints, response fields, and enum values:
- Ignore unrecognized response fields.
- Preserve unrecognized enum values as strings or decode them into an unknown case.
Apply these settings to generated clients too.
Last updated on
