Validate a project expense
Dry-runs the business rules of project-expense creation on a body, without saving anything.
Takes the same body as POST /v1/project-expense and checks input validation, project existence, currency code, and the expense business rules (expense type, active project, contract on project and not closed, fixed-price → billable, supplier, payment method, closed accounting period of the caller). Answers 200 with a literal null when they pass.
Use it to:
- BPP / AP automation pre-checking each supplier-invoice line before posting, to route failures to a human queue
- Form validation in a client before the user confirms
Not for:
- Guaranteeing that create will succeed (licence, exchange rate and length errors are only detected by create)
Rules across fields:
- R1: Send at most one of ProfitRatio and SalesPriceAmountProjectCurrency.
- R2: ProjectSubContractID must be an open contract of ProjectID.
- R3: IsBillable must be true when the contract is fixed-price.
Behaviour:
- Idempotent.
- Safe to retry.
- Changes are visible to the next read immediately.
Call first: GET /v1/contract, GET /v1/expense-type/all/list
Next: POST /v1/project-expense
Not yet documented: examples.
curl -X POST "https://app3.timelog.com/your-account/api/v1/project-expense/validate-project-expense" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-d '{
"ProjectID": 123,
"ProjectSubContractID": 123,
"PurchaseDate": "2024-12-25T10:00:00Z",
"PaymentMethodID": 123,
"ExpenseTypeID": 123,
"AmountIncludingVatExpenseCurrency": 3.14,
"VatAmountExpenseCurrency": 3.14,
"ExpenseCurrencyISO": "example_string",
"ProjectExpenseExchangeRate": "null",
"IsBillable": true,
"SalesPriceAmountProjectCurrency": "null",
"ProfitRatio": "null",
"Comment": "null",
"ExpenseNo": "null",
"SupplierID": "null",
"SupplierInvoiceNo": "null",
"ExternalID": "null"
}'
import requests
import json
url = "https://app3.timelog.com/your-account/api/v1/project-expense/validate-project-expense"
headers = {
"Content-Type": "application/json",
"Authorization": "Bearer YOUR_API_TOKEN"
}
data = {
"ProjectID": 123,
"ProjectSubContractID": 123,
"PurchaseDate": "2024-12-25T10:00:00Z",
"PaymentMethodID": 123,
"ExpenseTypeID": 123,
"AmountIncludingVatExpenseCurrency": 3.14,
"VatAmountExpenseCurrency": 3.14,
"ExpenseCurrencyISO": "example_string",
"ProjectExpenseExchangeRate": "null",
"IsBillable": true,
"SalesPriceAmountProjectCurrency": "null",
"ProfitRatio": "null",
"Comment": "null",
"ExpenseNo": "null",
"SupplierID": "null",
"SupplierInvoiceNo": "null",
"ExternalID": "null"
}
response = requests.post(url, headers=headers, json=data)
print(response.json())
const response = await fetch("https://app3.timelog.com/your-account/api/v1/project-expense/validate-project-expense", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer YOUR_API_TOKEN"
},
body: JSON.stringify({
"ProjectID": 123,
"ProjectSubContractID": 123,
"PurchaseDate": "2024-12-25T10:00:00Z",
"PaymentMethodID": 123,
"ExpenseTypeID": 123,
"AmountIncludingVatExpenseCurrency": 3.14,
"VatAmountExpenseCurrency": 3.14,
"ExpenseCurrencyISO": "example_string",
"ProjectExpenseExchangeRate": "null",
"IsBillable": true,
"SalesPriceAmountProjectCurrency": "null",
"ProfitRatio": "null",
"Comment": "null",
"ExpenseNo": "null",
"SupplierID": "null",
"SupplierInvoiceNo": "null",
"ExternalID": "null"
})
});
const data = await response.json();
console.log(data);
package main
import (
"fmt"
"net/http"
"bytes"
"encoding/json"
)
func main() {
data := []byte(`{
"ProjectID": 123,
"ProjectSubContractID": 123,
"PurchaseDate": "2024-12-25T10:00:00Z",
"PaymentMethodID": 123,
"ExpenseTypeID": 123,
"AmountIncludingVatExpenseCurrency": 3.14,
"VatAmountExpenseCurrency": 3.14,
"ExpenseCurrencyISO": "example_string",
"ProjectExpenseExchangeRate": "null",
"IsBillable": true,
"SalesPriceAmountProjectCurrency": "null",
"ProfitRatio": "null",
"Comment": "null",
"ExpenseNo": "null",
"SupplierID": "null",
"SupplierInvoiceNo": "null",
"ExternalID": "null"
}`)
req, err := http.NewRequest("POST", "https://app3.timelog.com/your-account/api/v1/project-expense/validate-project-expense", bytes.NewBuffer(data))
if err != nil {
panic(err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer YOUR_API_TOKEN")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
fmt.Println("Response Status:", resp.Status)
}
require 'net/http'
require 'json'
uri = URI('https://app3.timelog.com/your-account/api/v1/project-expense/validate-project-expense')
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
request = Net::HTTP::Post.new(uri)
request['Content-Type'] = 'application/json'
request['Authorization'] = 'Bearer YOUR_API_TOKEN'
request.body = '{
"ProjectID": 123,
"ProjectSubContractID": 123,
"PurchaseDate": "2024-12-25T10:00:00Z",
"PaymentMethodID": 123,
"ExpenseTypeID": 123,
"AmountIncludingVatExpenseCurrency": 3.14,
"VatAmountExpenseCurrency": 3.14,
"ExpenseCurrencyISO": "example_string",
"ProjectExpenseExchangeRate": "null",
"IsBillable": true,
"SalesPriceAmountProjectCurrency": "null",
"ProfitRatio": "null",
"Comment": "null",
"ExpenseNo": "null",
"SupplierID": "null",
"SupplierInvoiceNo": "null",
"ExternalID": "null"
}'
response = http.request(request)
puts response.body
"null"
{
"error": "Bad Request",
"message": "The request contains invalid parameters or malformed data",
"code": 400,
"details": [
{
"field": "email",
"message": "Invalid email format"
}
]
}
{
"error": "Unauthorized",
"message": "Authentication required. Please provide a valid API token",
"code": 401
}
{
"error": "Internal Server Error",
"message": "An unexpected error occurred on the server",
"code": 500,
"requestId": "req_1234567890"
}
/v1/project-expense/validate-project-expense
Target server for requests. Edit to use your own host.
OAuth 2.0 bearer token, sent as Authorization: Bearer <token>. JWT and reference tokens are both accepted. The token must carry the required scope (default tlp).
The token's sub claim is the acting user. A token WITHOUT sub (client-credentials) acts as the system service user. If the sub user cannot log in (inactive, locked, deleted), the request is rejected with 401 — it is no longer let through without a user.
> Not yet documented: how to obtain a token.
> Not yet documented: token lifetime.
Authorization: Bearer <token>. JWT and reference tokens are both accepted. The token must carry the required scope (default tlp).
The token's sub claim is the acting user. A token WITHOUT sub (client-credentials) acts as the system service user. If the sub user cannot log in (inactive, locked, deleted), the request is rejected with 401 — it is no longer let through without a user.
> Not yet documented: how to obtain a token.
> Not yet documented: token lifetime.The media type of the request body
The project the cost is booked on. Must exist (37014) and be active (37689).
Refers to ProjectRead.ProjectID.
- ≤ 0 — 400 Code 200 "'Project ID' must be greater than '0'."
- unknown ID — 37014 (adapter, before any other business rule).
- inactive project — 37689.
> Not yet documented: values → lookup op.
The contract of ProjectID the cost is booked on. Required — there is NO default-contract resolution for project expenses.
Refers to ContractRead.ContractID. Look up valid values with GET /v1/contract.
- ≤ 0 — 400 Code 200.
- not a contract of ProjectID — 37042.
- contract closed — 37806.
- fixed-price contract and IsBillable false — 31140.
Valid values come from GET /v1/contract.
The purchase / supplier-invoice date. Date only — no time component. - a time component is sent — NOT truncated: stored in a SMALLDATETIME (minute precision). Send a date without time — the per-day expense totals match on the exact value.
How the cost was paid. A payment method flagged as the employee's own money makes the expense reimbursable to the caller.
Refers to PaymentMethodRead.PaymentMethodID.
- ≤ 0 — 400 Code 200.
- unknown ID — 37018.
> Not yet documented: values → lookup op.
The expense type (cost category). Drives the product number on the invoice line when product numbers are active.
Refers to ExpenseTypeRead.ExpenseTypeID. Look up valid values with GET /v1/expense-type/all/list.
- ≤ 0 — 400 Code 200.
- unknown ID — 37019.
- type of another legal entity than the project's — Accepted — the legal-entity rule sets violation 37998 but returns true, so it is never raised.
Valid values come from GET /v1/expense-type/all/list.
The cost INCLUDING VAT, in the purchase currency ExpenseCurrencyISO. The net cost is this minus VatAmountExpenseCurrency. Unit: currency ExpenseCurrencyISO (purchase currency), VAT included - more decimals than the account's invoice setting — 400 "Number of decimals are wrong on AmountIncludingVatExpenseCurrency…" - 0 — A zero-cost line: the sales price is taken from SalesPriceAmountProjectCurrency as is (pure re-invoicing). No sign rule: negative amounts (credit notes) are not rejected by the API.
The VAT part of AmountIncludingVatExpenseCurrency, in the purchase currency. Unit: currency ExpenseCurrencyISO Not checked against the amount or the expense type's VAT %; VAT is never invoiced (cost and sales price are net).
ISO code of the purchase currency (e.g. "DKK", "EUR") — a currency abbreviation known to the account.
Refers to CurrencyRead.CurrencyABB. Look up valid values with GET /v1/currency/{status}.
- empty — 400 Code 200.
- unknown abbreviation — 30020 "Unknown Currency Abbreviation" (500 Code 102).
Valid values come from GET /v1/currency/{status}.
Rate from the purchase currency to the CALLER's legal-entity currency, per 100 units (100 = same value; 745.5 = 100 EUR cost 745.50 DKK). Stored as the expense's base-reference rate and used for the employee-side cost and reimbursement amounts. Null: Omitted / null → looked up in the currency matrix (intercompany exchange rate) for PurchaseDate; none found → 400. Unit: caller legal-entity currency per 100 units of ExpenseCurrencyISO - omitted and no currency-matrix rate exists for the date — 400 "No exchange rate found for the expense date between expense currency and employee legal entity currency." - omitted — Matrix rate (create only — the validate route does not look it up). - omitted and SalesPriceAmountProjectCurrency sent — The profit ratio is computed with a rate of 0 → stored as 0. The project-side conversion (cost and sales price in project currency, project legal-entity cost) does NOT use this value: it uses the account's live / matrix rates for the date.
Whether the cost is re-invoiced to the customer. When false the sales price in project currency is stored as 0. - false on a fixed-price contract — 31140. - omitted — false (non-nullable bool) — the expense is NOT billable unless you send true.
The price to invoice the customer, in the PROJECT's currency, net of VAT. Alternative to ProfitRatio. Conditional. Must be null when ProfitRatio is sent. Null: Null (and ProfitRatio null) → sales price = net cost converted to project currency (no mark-up). Unit: project currency, VAT excluded - sent together with ProfitRatio — 400 "SalesPriceAmountCurrency must be null when ProfitRatio is added." - sent, amount ≠ 0 — ProfitRatio is derived from it ((sales − cost) / cost × 100, in system currency) — only when ProjectExpenseExchangeRate is also sent. - 0 — Treated as not sent: recomputed from cost × (1 + ProfitRatio / 100).
Mark-up in percent on the net cost: sales price = cost × (1 + ProfitRatio / 100). Alternative to SalesPriceAmountProjectCurrency. Conditional. Must be null when SalesPriceAmountProjectCurrency is sent. Null: Null → 0 % (the adapter always sends a ratio, so the account's default ExpenseProfitPercent is NOT applied to API-created project expenses). Unit: percent - sent together with SalesPriceAmountProjectCurrency — 400.
Free-text description of the cost; also used in the invoice line text.
The expense number. Conditional. Used only when the ExpenseNo feature is on, the number series does not auto-generate, and AllowEmptyExpenseNo is on; otherwise ignored (auto number or NULL). - ExpenseNo feature off, or the series auto-generates — Adapter drops the value. - ExpenseNumberSeries on — Next number from the series. > Not yet documented: behaviour.
The supplier — a CRM customer whose status is shown in suppliers. Returned as ProjectExpenseRead.CustomerID.
Refers to CustomerRead.CustomerID. Look up valid values with GET /v1/customer.
Null: Null or 0 = no supplier.
- unknown customer — 37021.
- customer without a supplier status — 37940.
Valid values come from GET /v1/customer.
The supplier's invoice number. Returned as ProjectExpenseRead.InvoiceNo. - longer than 50 characters — Silently cut to 50.
Your system's identifier for this cost. Stored and returned; not unique, not used for lookups. Key from your own system. Uniqueness is not enforced. - longer than 50 characters — The insert fails → 500 Code 100. Retries are NOT deduplicated on ExternalID — check before re-sending a create whose response was lost.
Request Preview
Response
Response will appear here after sending the request
Authentication
Bearer token. OAuth 2.0 bearer token, sent as Authorization: Bearer <token>. JWT and reference tokens are both accepted. The token must carry the required scope (default tlp).
The token's sub claim is the acting user. A token WITHOUT sub (client-credentials) acts as the system service user. If the sub user cannot log in (inactive, locked, deleted), the request is rejected with 401 — it is no longer let through without a user.
Not yet documented: how to obtain a token.
Not yet documented: token lifetime.
Body
The project the cost is booked on. Must exist (37014) and be active (37689).
Refers to ProjectRead.ProjectID.
-
≤ 0 — 400 Code 200 "'Project ID' must be greater than '0'."
-
unknown ID — 37014 (adapter, before any other business rule).
-
inactive project — 37689.
Not yet documented: values → lookup op.
The contract of ProjectID the cost is booked on. Required — there is NO default-contract resolution for project expenses.
Refers to ContractRead.ContractID. Look up valid values with GET /v1/contract.
-
≤ 0 — 400 Code 200.
-
not a contract of ProjectID — 37042.
-
contract closed — 37806.
-
fixed-price contract and IsBillable false — 31140.
Valid values come from GET /v1/contract.
The purchase / supplier-invoice date.
Date only — no time component.
- a time component is sent — NOT truncated: stored in a SMALLDATETIME (minute precision). Send a date without time — the per-day expense totals match on the exact value.
How the cost was paid. A payment method flagged as the employee's own money makes the expense reimbursable to the caller.
Refers to PaymentMethodRead.PaymentMethodID.
-
≤ 0 — 400 Code 200.
-
unknown ID — 37018.
Not yet documented: values → lookup op.
The expense type (cost category). Drives the product number on the invoice line when product numbers are active.
Refers to ExpenseTypeRead.ExpenseTypeID. Look up valid values with GET /v1/expense-type/all/list.
-
≤ 0 — 400 Code 200.
-
unknown ID — 37019.
-
type of another legal entity than the project's — Accepted — the legal-entity rule sets violation 37998 but returns true, so it is never raised.
Valid values come from GET /v1/expense-type/all/list.
The cost INCLUDING VAT, in the purchase currency ExpenseCurrencyISO. The net cost is this minus VatAmountExpenseCurrency.
Unit: currency ExpenseCurrencyISO (purchase currency), VAT included
-
more decimals than the account's invoice setting — 400 "Number of decimals are wrong on AmountIncludingVatExpenseCurrency…"
-
0 — A zero-cost line: the sales price is taken from SalesPriceAmountProjectCurrency as is (pure re-invoicing).
No sign rule: negative amounts (credit notes) are not rejected by the API.
The VAT part of AmountIncludingVatExpenseCurrency, in the purchase currency.
Unit: currency ExpenseCurrencyISO
Not checked against the amount or the expense type's VAT %; VAT is never invoiced (cost and sales price are net).
ISO code of the purchase currency (e.g. "DKK", "EUR") — a currency abbreviation known to the account.
Refers to CurrencyRead.CurrencyABB. Look up valid values with GET /v1/currency/{status}.
-
empty — 400 Code 200.
-
unknown abbreviation — 30020 "Unknown Currency Abbreviation" (500 Code 102).
Valid values come from GET /v1/currency/{status}.
Rate from the purchase currency to the CALLER's legal-entity currency, per 100 units (100 = same value; 745.5 = 100 EUR cost 745.50 DKK). Stored as the expense's base-reference rate and used for the employee-side cost and reimbursement amounts.
Null: Omitted / null → looked up in the currency matrix (intercompany exchange rate) for PurchaseDate; none found → 400.
Unit: caller legal-entity currency per 100 units of ExpenseCurrencyISO
-
omitted and no currency-matrix rate exists for the date — 400 "No exchange rate found for the expense date between expense currency and employee legal entity currency."
-
omitted — Matrix rate (create only — the validate route does not look it up).
-
omitted and SalesPriceAmountProjectCurrency sent — The profit ratio is computed with a rate of 0 → stored as 0.
The project-side conversion (cost and sales price in project currency, project legal-entity cost) does NOT use this value: it uses the account's live / matrix rates for the date.
Whether the cost is re-invoiced to the customer. When false the sales price in project currency is stored as 0.
-
false on a fixed-price contract — 31140.
-
omitted — false (non-nullable bool) — the expense is NOT billable unless you send true.
The price to invoice the customer, in the PROJECT's currency, net of VAT. Alternative to ProfitRatio.
Conditional. Must be null when ProfitRatio is sent.
Null: Null (and ProfitRatio null) → sales price = net cost converted to project currency (no mark-up).
Unit: project currency, VAT excluded
-
sent together with ProfitRatio — 400 "SalesPriceAmountCurrency must be null when ProfitRatio is added."
-
sent, amount ≠ 0 — ProfitRatio is derived from it ((sales − cost) / cost × 100, in system currency) — only when ProjectExpenseExchangeRate is also sent.
-
0 — Treated as not sent: recomputed from cost × (1 + ProfitRatio / 100).
Mark-up in percent on the net cost: sales price = cost × (1 + ProfitRatio / 100). Alternative to SalesPriceAmountProjectCurrency.
Conditional. Must be null when SalesPriceAmountProjectCurrency is sent.
Null: Null → 0 % (the adapter always sends a ratio, so the account's default ExpenseProfitPercent is NOT applied to API-created project expenses).
Unit: percent
- sent together with SalesPriceAmountProjectCurrency — 400.
Free-text description of the cost; also used in the invoice line text.
The expense number.
Conditional. Used only when the ExpenseNo feature is on, the number series does not auto-generate, and AllowEmptyExpenseNo is on; otherwise ignored (auto number or NULL).
-
ExpenseNo feature off, or the series auto-generates — Adapter drops the value.
-
ExpenseNumberSeries on — Next number from the series.
Not yet documented: behaviour.
The supplier — a CRM customer whose status is shown in suppliers. Returned as ProjectExpenseRead.CustomerID.
Refers to CustomerRead.CustomerID. Look up valid values with GET /v1/customer.
Null: Null or 0 = no supplier.
-
unknown customer — 37021.
-
customer without a supplier status — 37940.
Valid values come from GET /v1/customer.
The supplier's invoice number. Returned as ProjectExpenseRead.InvoiceNo.
- longer than 50 characters — Silently cut to 50.
Your system's identifier for this cost. Stored and returned; not unique, not used for lookups.
Key from your own system. Uniqueness is not enforced.
- longer than 50 characters — The insert fails → 500 Code 100.
Retries are NOT deduplicated on ExternalID — check before re-sending a create whose response was lost.
Responses
Success without content: HTTP 200 with the literal JSON body null. Literal JSON null — all checked rules pass.