Answers to the questions developers ask us most often. For full details of every endpoint, see the API Documentation and the Authentication and OAuth 2.0 guide.
Please complete our API application form. You can use the API on Crunch Free, Pro or Premium. The form asks whether your app is confidential (it runs on a secure server) or public (it’s a desktop, mobile or browser app); see Do I need a client secret? By using the API, you accept the Developer Terms of Service.
Yes, that’s fine. Using the same email address for both is expected, so you don’t need to change anything.
There isn’t a separate login: the API uses your normal Crunch login, and logging in at accounts.crunch.co.uk shows your usual accounting dashboard. The API has no user interface: you call it from code or from an API client such as Postman. This Developer Portal is documentation only. If you were sent a password during API setup, you can ignore it.
There isn’t a sandbox or demo account, but you can create a free Crunch account and test against that.
Everything the API supports is listed in the API Documentation, where each service has its own page. If an endpoint isn’t documented there, it isn’t available.
Unfortunately not. Endpoints that return a 403 error to public API clients, such as /banking/statement-entries, serve the Crunch app only and aren’t part of the public API, so no elevated scope, partner tier or allow-list grants access to them.
Use the Feedback tab on the right-hand side of the Crunch app.
It depends on the client type you chose when you registered.
| Client type | Typical use | Client secret | PKCE |
|---|---|---|---|
| Confidential | Server-side or backend integrations | Yes, sent in your onboarding email | Optional |
| Public | Desktop, CLI, mobile or browser-only apps | No secret is issued | Required |
If you received a client_id but no secret, you’re most likely registered as a public client and must use PKCE.
Send a POST to https://oauth.crunch.co.uk/token with a form-encoded body (application/x-www-form-urlencoded), not URL query parameters. In Postman, either use the Body tab and select x-www-form-urlencoded, or use the Authorization tab with type OAuth 2.0 to run the whole flow and fetch the token for you.
Exchange the code straight away, as it’s short-lived. Confidential clients should authenticate with HTTP Basic auth (client_id as the username, client_secret as the password) and then must not also send them in the body. Public clients send client_id and code_verifier instead. If you sent redirect_uri to /authorize, send the identical value here.
# Confidential client (Basic auth)
curl -X POST https://oauth.crunch.co.uk/token \
-u YOUR_CLIENT_ID:YOUR_CLIENT_SECRET \
-d grant_type=authorization_code \
-d code=AUTH_CODE \
-d redirect_uri=http://127.0.0.1:8080/callback
# Public client (PKCE)
curl -X POST https://oauth.crunch.co.uk/token \
-d grant_type=authorization_code \
-d code=AUTH_CODE \
-d redirect_uri=http://127.0.0.1:8080/callback \
-d client_id=YOUR_CLIENT_ID \
-d code_verifier=YOUR_PKCE_CODE_VERIFIER
Then send the access token as a Bearer token on every API call:
curl https://public-api.crunch.co.uk/v1/expenses \
-H "Authorization: Bearer ACCESS_TOKEN"
There are no permanent API keys or non-expiring tokens. Access tokens currently last 1 hour, but read the exact lifetime from expires_in in the token response rather than hard-coding it. Request the offline_access scope to receive a refresh token, which lasts 60 days, and refresh when the access token expires; your integration can then run unattended.
# Confidential client
curl -X POST https://oauth.crunch.co.uk/token \
-u YOUR_CLIENT_ID:YOUR_CLIENT_SECRET \
-d grant_type=refresh_token \
-d refresh_token=YOUR_LATEST_REFRESH_TOKEN
# Public client
curl -X POST https://oauth.crunch.co.uk/token \
-d grant_type=refresh_token \
-d refresh_token=YOUR_LATEST_REFRESH_TOKEN \
-d client_id=YOUR_CLIENT_ID
Always save and use the newest refresh token. Applications created since 14 January 2026 have refresh token rotation switched on: each refresh returns a new refresh token and invalidates the old one. Reusing an old refresh token fails and revokes the whole grant, so the user has to authorise again.
Scopes are all with an optional CRUD prefix: create:all, read:all, update:all and delete:all, or plain all for everything. Add offline_access to get a refresh token, and separate scopes with spaces. Each endpoint’s required scope is listed under Security in the API Documentation; for example, creating an expense needs create:all and deleting one needs delete:all.
Yes, because your client_id belongs to your application, not to a Crunch account. Run the authorisation flow once per account, logging in with that account’s credentials, and store each account’s tokens separately.
No, you don’t need to. Pass the redirect URI you need in the redirect_uri parameter of your authorisation request, and send the same value in the token request.
Include it unless a redirect URI is already set on your API account; otherwise, leaving it out causes an invalid_redirect_uri error. If a redirect URI is set on your account, any value you send must match it. You can use different URIs for development and production, as long as no redirect URI is set on your account.
Yes, but only over http, not https. For example, http://localhost:3090/callback or http://127.0.0.1:8080/callback.
The browser is redirecting to your callback, but nothing is listening there. Make sure your local app is running on the redirect port before you start the flow. Authorisation codes are single-use, so clicking Allow a second time shows an error; restart the flow instead.
/. What’s the full URL?Paths are relative to each service’s base URL, shown under Access at the top of its documentation page. Add the path to the base URL. For example, Create Expense is listed as post / under the base URL https://public-api.crunch.co.uk/v1/expenses, so the full request is:
POST https://public-api.crunch.co.uk/v1/expenses
The same applies to Clients (/v1/clients), Invoices (/v1/invoices), Banking (/v1/banking) and the other services.
List endpoints accept page (starting at 0), size (default 20, maximum 200) and sort (field,asc or field,desc). Keep requesting the next page until the response has "last": true.
GET https://public-api.crunch.co.uk/v1/expenses?page=0&size=200&sort=date,desc
Yes, the limit is 100 requests per second.
Look them up from the API rather than guessing; invalid values are the most common cause of validation errors.
| You need | Endpoint |
|---|---|
Expense types (expenseType) |
GET /v1/expenses/expense-types |
VAT types (vatType) |
GET /v1/expenses/vat-types |
Payment methods (paymentMethodName) |
GET /v1/expenses/payment-methods |
Director IDs for DIRECTORS_EXPENSES |
GET /v1/expenses/persons?date=YYYY-MM-DD |
| An existing supplier by name | GET /v1/expenses/suppliers?supplierName={supplier_name} |
| Open accounting periods (closed years can’t be edited) | GET /v1/expenses/ledgers |
| Bank account IDs | GET /v1/banking/bank-accounts |
| Customer IDs for invoices | GET /v1/clients/list |
| Country codes | GET /v1/immutable-data/countries |
Send a POST to /v1/expenses/suppliers. Only companyName is required; expenseType, country and vatRegistrationNumber are optional.
{
"companyName": "Example Supplier Ltd",
"expenseType": { "expenseTypeName": "TELEPHONE" }
}
Send a POST to /v1/expenses. This needs the create:all scope. Use the id of an existing supplier, or create the supplier first.
{
"supplier": { "id": SUPPLIER_ID },
"postingDate": "2026-09-14",
"expenseLineItems": [
{
"expenseType": "ACCOUNTANCY",
"description": "Monthly bookkeeping",
"vatType": "Standard",
"amount": { "grossAmount": "120.00" }
}
],
"supplierPayments": [
{
"date": "2026-09-14",
"amount": 120,
"paymentTarget": {
"paymentMethod": { "paymentMethodName": "EFT" },
"bankAccount": { "id": BANK_ACCOUNT_ID }
}
}
]
}
vatType takes display names such as Standard, Exempt and Outside the Scope, not code-style values. For the DIRECTORS_EXPENSES payment method, add the director as person: { "id": PERSON_ID } inside paymentTarget.
Include the file in the attachments array as a full data URI, with the MIME type prefix. Raw base64 without the prefix is accepted but saves an empty file.
"attachments": [
{
"id": 0,
"fileName": "receipt.jpeg",
"fileContents": "data:image/jpeg;base64,YOUR_BASE64_STRING"
}
]
id numbers the attachments within the expense: 0, 1, 2 and so on.
fileContents null when I fetch an expense?This is by design, to keep responses small. Download the file separately:
GET /v1/expenses/attachments?expenseId={expense_id}&attachmentId={attachment_id}&fileName={file_name}
To delete the whole expense, send a DELETE to /v1/expenses/{expense_id}. To remove only the payment, send a PUT to the same endpoint with the supplierPayments section removed. You can’t delete a reconciled payment or an expense in a closed year.
In the API, customers are called clients. Send a POST to /v1/clients, then use the returned id as customer.id on the invoice. To find existing customers, use GET /v1/clients/list.
{
"name": "Example Customer Ltd",
"email": "accounts@example.com",
"paymentTermsDays": 30,
"billingAddress": {
"line1": "1 Example Street",
"line2": "Example Town",
"postCode": "AB1 2CD",
"country": "GB"
}
}
Send a POST to /v1/invoices. A 500 error usually means a required field is missing, so use this example body as a starting point.
{
"customer": { "id": CUSTOMER_ID },
"currency": "POUND_STERLING",
"issuedDate": "2026-09-05",
"lineItemType": "SIMPLE",
"items": [
{
"description": "Consulting services",
"priceExclVat": "100.00",
"quantity": 1,
"vatRate": "Outside the Scope",
"lineType": "S"
}
],
"bankAccount": { "id": BANK_ACCOUNT_ID },
"payment": { "paymentTerms": { "days": 28 } },
"attachments": []
}
The invoice must be issued. Fetch it with GET /v1/invoices/{invoice_id}, add your new entry to the payments array, then send the whole invoice back with PUT /v1/invoices/{invoice_id}. This records the payment but doesn’t reconcile the matching bank entry.
Don’t send only the new payment. Any existing payment missing from payments is deleted, and any attachment missing from attachments is removed. Keep the existing entries exactly as the GET request returned them, and add your new payment without an id.
A new payment entry looks like this:
{
"amount": "120.00",
"date": "2026-09-30",
"paymentTarget": {
"paymentMethod": { "paymentMethodName": "EFT" },
"bankAccount": { "id": BANK_ACCOUNT_ID }
}
}
Not yet. The API supports GBP only. Invoices in other currencies are planned, with no date set.
Not through the API, which only works with new (Beta) invoices. You can still create classic invoices in the Crunch app.
| Symptom | Likely cause | Fix |
|---|---|---|
404 on /token |
Parameters sent as URL query parameters | Send them in a form-encoded body |
401 invalid_client on /token |
Client secret missing, wrong or not matching your client type | Confidential clients: send client_secret. Public clients: omit it and send code_verifier |
invalid_redirect_uri |
redirect_uri missing from the authorisation request, and no redirect URI set on your API account |
Include redirect_uri |
| Browser spins after clicking Allow | Nothing listening on your callback | Start your local listener first, then restart the flow |
404 on /v1/bank-accounts |
Wrong path | Use /v1/banking/bank-accounts; banking endpoints sit under /v1/banking/ |
403 on a /banking/... endpoint |
Internal endpoint, not part of the public API | Use only documented endpoints |
| 500 when creating an invoice | Missing required fields | Compare your body with the example invoice |
| Attachment downloads as a zero-byte file | fileContents sent without the data URI prefix |
Use data:<mime-type>;base64,... |
404 on /v1/attachments |
Wrong path | Use /v1/expenses/attachments |