Invoices
A business invoice is a charge you issue to a buyer: TEDR Pay stores it and hands back a link to the hosted payment form. One route creates every kind of invoice, and the type field in the body decides which of the other fields apply. What happens on the payment form afterwards — the quote, the rate, the address and the payment attempts — is a separate contract; this page is about the invoice itself.
Every endpoint here works only on your own invoices and accepts either a dashboard session or a request signed with an API key, so the same calls serve the web app and your backend — see Authentication. To learn that an invoice has been paid without polling it, use Webhooks.
Invoice types
There is no endpoint per type. The type you send on creation selects the rules below and comes back unchanged on every read. There is no update route at all: once an invoice exists, only the three commands at the bottom of this page change anything about it, and they only change its state.
| Type | What it is | Type-specific rules |
|---|---|---|
| PAYME | A one-off invoice for a fixed amount. | amount is required. Cancellation is refused once a payment has started. |
| GOODS_SERVICES | A one-off invoice with line items. | items[] is required and orderId is optional. Cancellation is refused once a payment has started. |
| DONATION | A standing donation with no fixed amount. | title applies to this type only, and the amount on the detail card is always null for a donation, whatever it has collected — what came in is reported by other fields, see Amounts and base currency. It can be cancelled whatever it has already received. |
| SUBSCRIPTION | A recurring payment. | amount is required, and the plan is created in the dashboard only. Cancelling it does not depend on payments and is final; once cancelled, it can be deleted, provided none of its period invoices has a payment attempt or receipt. |
SUBSCRIPTION plans are created in the dashboard only
A subscription plan is created in the TEDR Pay dashboard, under a session JWT. The create route refuses this type when the request is signed with an API key and answers <code>403 SUBSCRIPTION_WEB_ONLY</code>. Once the plan exists, it can be read and managed — edited, paused, resumed, cancelled and deleted — with either a session JWT or a signed API key. Cancelling a plan is final and does not depend on what its subscribers have paid; after that it can be deleted, but only while none of its period invoices has a payment attempt or receipt.
The product image catalog is not documented here
A line item may carry a productImageId; the image behind it is snapshotted into the invoice at creation and read back as imageUrl on that item. Where those ids come from — how an image gets into your catalog and how to list what is in it — is outside this version of the reference, so nothing on these pages will hand you one.
Amounts and base currency
An invoice is denominated in one fiat currency, fixed when it is created: it takes the base currency from your merchant profile, which is USD unless you changed it. That choice is frozen into the invoice together with the rest of your merchant settings, so nothing you change later rewrites it.
The amount is stored as baseAmount, in minor units of that base currency — 10000 for an invoice of 100.00 in a currency with two decimals. Both that name and that scale belong to storage and to the search branch below, not to a response body: on a list row the same amount arrives as totalAmount, spelled out however the response spells money out. Storing in minor units is also why searching by amount only ever matches exactly.
A donation is the exception. The amount inside data on its detail card is null whatever it has collected, so do not poll the card for a running total. What it has received is totalAmount on its list row — the sum of all its receipts converted into the base currency — and the same sum is payments.totalBaseAmount on the card. For every other type totalAmount on the list row is just the invoice's own base amount.
Changing the base currency on your profile affects only invoices created afterwards and how statistics are displayed. Invoices that already exist, their fees and the sums they have received keep the currency they were created in.
Policy limits are a separate scale: they are defined in USD, so the amount of a new invoice is checked against its USD equivalent whatever currency the invoice itself is in.
This reference does not pin the shape of money in a response
baseAmount is defined in minor units, but how a money value is spelled out in a response body is not something this version of the documentation fixes: the amounts in the example responses below are placeholders, not samples of the real format. Read them back from the response instead of writing a parser against an example.
The request side is fixed and the request examples are literal: amount and unitPrice are decimal strings, sent exactly as they appear there.
Expiry
expiresAt on the invoice is the source of truth for when it stops accepting payment, and it is nullable: types that have no deadline carry null there. Invoices that do have one are given 30 days from creation today.
Treat those 30 days as the value currently in force, not as part of the contract — it is a server-side setting, and a client that recomputes the deadline itself drifts away from the invoice the day it changes. Once expiresAt has passed, the invoice can no longer be reactivated.
Statuses
Two different vocabularies appear in these responses. The invoice has its own lifecycle status, returned as status on the card and on every list row. A payment attempt against that invoice has a separate status, and that is what the rows of the payments list report.
The invoice statuses this page depends on are ACTIVE, CANCELED and EXPIRED. The attempt statuses that together mean "a payment has started" are PROCESSING, PARTIALLY_PAID, BLOCKED, PAID, PAID_LATE and REFUNDED; that is the set the cancel and delete guards check.
Neither list is guaranteed complete
This version of the documentation has no status reference of its own, so the values above are the ones this page relies on rather than a closed set. Do not write an exhaustive branch over them. The displayStatus filter on the list takes its own vocabulary, which is not published here either.
Searching and paging
search is one OR group, and which branches it contains depends on the shape of what you typed. The query itself must be 3 to 100 characters.
| What you typed | Branch added | How it matches |
|---|---|---|
| A full UUID | id | Exact equality. An id is never matched partially. |
| Digits, optionally with a decimal part | baseAmount | Exact equality after conversion to minor units. "100" finds an invoice whose baseAmount is 10000, and "1" does not find "100" — there is no substring search on money. |
| Anything at all | description | Case-insensitive substring match. This branch is always present. |
Filter groups combine with AND and the values inside a group with IN. An empty value such as ?type= is not a filter at all, while an unknown value — or an unknown parameter name — is a 400.
Paging is keyset rather than offset. The sort is always createdAt and then id as a tiebreak, both running in the direction given by sortOrder, and the cursor carries a fingerprint of the filters and of sortOrder. Change a filter or the direction and the cursor no longer matches: the server answers 400 and you list again from the first page. limit is deliberately outside that fingerprint, so the page size may change as you go. totalCount is the size of the whole filtered set, not of what is left after the cursor.
Soft-deleted invoices never appear in the list, whatever filters you send.
How the id is checked
The five endpoints that take an id — the card, the payments list and the three commands — run the same ownership check, in the same order, before they do anything else.
| What you sent | Response |
|---|---|
| A string that is not a valid UUID | 400 VALIDATION_ERROR |
| A valid UUID no invoice has | 404 BUSINESS_INVOICE_NOT_FOUND |
| An invoice belonging to another merchant | 403 FORBIDDEN |
| An invoice you have already deleted | 404 BUSINESS_INVOICE_NOT_FOUND |
A 403 confirms the id exists
An invoice nobody has answers 404 while someone else's answers 403, so the two are not interchangeable: a 403 tells the caller that the id is real. This ordering is deliberate, and an invoice you deleted yourself falls back to 404 after the ownership check has passed.
Create an invoice
/invoicesAuth: JWT or API key signature
A single route creates invoices of every type — there is no per-type endpoint. type is the discriminator that decides which of the remaining fields apply.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| type | enum | Required | One of PAYME (one-off invoice for a fixed amount), GOODS_SERVICES (one-off invoice with line items), DONATION (standing donation) or SUBSCRIPTION (recurring payment). | |
| description | string | Optional | Free-form text carried on the invoice. The list search matches against it. | |
| metadata | object | Optional | Arbitrary JSON object stored with the invoice. | |
| allowedCryptoCurrencies | string[] (coin codes) | Optional | The subset of the coins you accept that this invoice may be paid in, for example ["USDT_TRX", "BTC"]. Omit it and the full merchant coin pool is frozen into the invoice at creation. | |
| amount | string | Required for PAYME and SUBSCRIPTION | Invoice amount, as a decimal string. Policy limits are checked against the USD equivalent. | |
| items[] | { title, quantity, unitPrice, productImageId? } | Required for GOODS_SERVICES | Line items. productImageId is an optional UUID of an image from your merchant catalog; the image is snapshotted into the invoice and read back as imageUrl. | |
| orderId | string | Optional | Your own order reference. GOODS_SERVICES only. | |
| title | string | Optional | Donation title. DONATION only. |
/invoicescurl -X POST 'https://dev-app.tedr.com/api/invoices' \
-H 'Authorization: Bearer YOUR_SESSION_JWT' \
-H 'Content-Type: application/json' \
-d '{
"type": "GOODS_SERVICES",
"orderId": "ORD-2041",
"items": [
{ "title": "Consulting hour", "quantity": 3, "unitPrice": "120.00" }
]
}'
const response = await fetch('https://dev-app.tedr.com/api/invoices', {
method: 'POST',
headers: {
Authorization: 'Bearer YOUR_SESSION_JWT',
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: 'GOODS_SERVICES',
orderId: 'ORD-2041',
items: [{ title: 'Consulting hour', quantity: 3, unitPrice: '120.00' }],
}),
});
const { invoice } = await response.json();
import requests
response = requests.post(
'https://dev-app.tedr.com/api/invoices',
headers={'Authorization': 'Bearer YOUR_SESSION_JWT'},
json={
'type': 'GOODS_SERVICES',
'orderId': 'ORD-2041',
'items': [
{'title': 'Consulting hour', 'quantity': 3, 'unitPrice': '120.00'},
],
},
)
invoice = response.json()['invoice']
Response: 201
{
"invoice": {
"businessInvoiceId": "6f1c1e2c-9b4c-4a2e-9f8b-0d5a1b2c3d4e",
"type": "GOODS_SERVICES",
"status": "ACTIVE",
"businessInvoiceUrl": "https://pay.tedr.com/pay/PbQ7x8kR2mT4",
"createdAt": "2026-07-30T10:15:00.000Z",
"expiresAt": "2026-08-29T10:15:00.000Z"
}
}
| Code | Status | When |
|---|---|---|
| VALIDATION_ERROR | 400 | The body failed validation: an unknown field, an unknown type, or an amount of zero or less. |
| PRODUCT_IMAGE_NOT_FOUND | 400 | productImageId is unknown, belongs to another merchant, or was removed from the catalog. |
| UNAUTHORIZED | 401 | The request carries no valid JWT. |
The 201 body is the minimal envelope — it carries no amount. Read the invoice by id for the full card. Validation is strict: an unknown field in the body or an unknown type is a 400. Creation writes a single row — no payment currency is chosen and no payment provider is called, and the merchant settings in force at that moment are frozen into the invoice. A server-to-server caller signs this request with an API key instead of a session JWT: see Authentication and API keys.
List invoices
/invoicesAuth: JWT or API key signature
Lists your invoices with filters, search and keyset pagination. Soft-deleted invoices never appear, whatever the filters.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| type | enum array | Optional | IN filter over the invoice types. Repeat the key: ?type=PAYME&type=DONATION. | |
| displayStatus | enum array | Optional | IN filter over the invoice display statuses. The set of accepted values is not published in this version of the reference — see the note below this table. | |
| search | string, 3–100 characters | Optional | One OR group over id, amount and description. A full UUID matches an id exactly; a 123 or 123.45 query matches the amount exactly, converted to minor units, so "100" does not find "1000"; description is always matched case-insensitively as a substring. | |
| periodFrom | string (ISO 8601) | Optional | Inclusive lower bound on createdAt. | |
| periodTo | string (ISO 8601) | Optional | Inclusive upper bound on createdAt. It is compared as an instant, so periodTo=2026-07-01 cuts off everything after midnight UTC — send the end of the day yourself. | |
| cursor | string | Optional | Position token taken from nextCursor. Omit it for the first page. | |
| limit | integer, 1–100 | Optional | 20 | Page size. |
| sortBy | 'createdAt' | Optional | createdAt | The only accepted value. |
| sortOrder | 'asc' | 'desc' | Optional | desc | Direction of both sort keys. |
/invoicescurl -G 'https://dev-app.tedr.com/api/invoices' \
-H 'Authorization: Bearer YOUR_SESSION_JWT' \
-d 'type=PAYME' \
-d 'type=DONATION' \
-d 'limit=20'
const query = new URLSearchParams({ limit: '20', sortOrder: 'desc' });
query.append('type', 'PAYME');
query.append('type', 'DONATION');
const response = await fetch(
`https://dev-app.tedr.com/api/invoices?${query}`,
{ headers: { Authorization: 'Bearer YOUR_SESSION_JWT' } },
);
const { invoices, totalCount, nextCursor } = await response.json();
import requests
response = requests.get(
'https://dev-app.tedr.com/api/invoices',
headers={'Authorization': 'Bearer YOUR_SESSION_JWT'},
params=[('type', 'PAYME'), ('type', 'DONATION'), ('limit', 20)],
)
page = response.json()
Response: 200
{
"invoices": [
{
"businessInvoiceId": "6f1c1e2c-9b4c-4a2e-9f8b-0d5a1b2c3d4e",
"businessInvoiceUrl": "https://pay.tedr.com/pay/PbQ7x8kR2mT4",
"status": "ACTIVE",
"type": "GOODS_SERVICES",
"totalAmount": "<the invoice's base amount, or null — see the notes>",
"createdAt": "2026-07-30T10:15:00.000Z"
}
],
"totalCount": 128,
"nextCursor": "Qx7R2mK9tB4wL0sVhN6ZpA3cJ8dF1gY5"
}
| Code | Status | When |
|---|---|---|
| VALIDATION_ERROR | 400 | An unknown query parameter or an unknown enum value, periodFrom later than periodTo, or a corrupted cursor — including one whose filters or sortOrder no longer match. |
| UNAUTHORIZED | 401 | The request carries no valid JWT. |
The set of displayStatus values is not published in this reference yet — it belongs with the invoice status reference, which this section does not cover. An empty value (?type=) means no filter at all; an unknown one is a 400, as is an unknown query parameter. Filter groups combine with AND, values inside a group with IN. Paging is keyset: the sort is always createdAt then id as a tiebreak, both in the same direction, and the cursor is an opaque token carrying a fingerprint of the filters and of sortOrder — change either and paging restarts. limit is not part of that fingerprint and may change between pages. totalCount is the size of the whole filtered set, not of what is left after the cursor. For the fixed types totalAmount is the invoice's own base amount; for an open donation it is null until money arrives, and then the sum of all receipts converted to your base currency. The JSON shape of a money value is not pinned by this version of the reference — read it from the response rather than assuming a decimal string.
Get an invoice
/invoices/:idAuth: JWT or API key signature
Returns the full invoice card: header fields, the type-specific data envelope, the payment currencies frozen at creation, and the first page of payments.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| id | string (UUID) | Required | The invoice id, returned as businessInvoiceId. |
/invoices/:idcurl 'https://dev-app.tedr.com/api/invoices/6f1c1e2c-9b4c-4a2e-9f8b-0d5a1b2c3d4e' \
-H 'Authorization: Bearer YOUR_SESSION_JWT'
const response = await fetch(
'https://dev-app.tedr.com/api/invoices/6f1c1e2c-9b4c-4a2e-9f8b-0d5a1b2c3d4e',
{ headers: { Authorization: 'Bearer YOUR_SESSION_JWT' } },
);
const invoice = await response.json();
import requests
response = requests.get(
'https://dev-app.tedr.com/api/invoices/6f1c1e2c-9b4c-4a2e-9f8b-0d5a1b2c3d4e',
headers={'Authorization': 'Bearer YOUR_SESSION_JWT'},
)
invoice = response.json()
Response: 200
{
"businessInvoiceId": "6f1c1e2c-9b4c-4a2e-9f8b-0d5a1b2c3d4e",
"type": "GOODS_SERVICES",
"status": "ACTIVE",
"businessInvoiceUrl": "https://pay.tedr.com/pay/PbQ7x8kR2mT4",
"createdAt": "2026-07-30T10:15:00.000Z",
"expiresAt": "2026-08-29T10:15:00.000Z",
"allowedCurrencies": "<payment currencies frozen at creation>",
"data": {
"items": [
{
"title": "Consulting hour",
"quantity": 3,
"unitPrice": "<unit price>",
"totalPrice": "<line total>",
"imageUrl": null
}
],
"totalAmount": "<invoice total>",
"orderId": "ORD-2041"
},
"payments": {
"items": [],
"totalCount": 0,
"nextCursor": null,
"totalBaseAmount": "<sum of the receipts, in the base currency>",
"baseCurrency": "USD"
}
}
| Code | Status | When |
|---|---|---|
| VALIDATION_ERROR | 400 | The id is not a valid UUID. |
| UNAUTHORIZED | 401 | The request carries no valid JWT. |
| FORBIDDEN | 403 | The invoice belongs to another merchant. |
| BUSINESS_INVOICE_NOT_FOUND | 404 | No invoice with this id exists, or it is already soft-deleted. |
The example above is abridged. It leaves out the merchant object — the merchant's first and last name — because this reference does not pin the field names inside it. Every money value on the card, including unitPrice and totalPrice on each line item, is a placeholder: the JSON shape of a money value is not pinned by this version of the reference either, so read it from the response rather than assuming a decimal string. So is allowedCurrencies, the snapshot of the currencies this invoice may be paid in. data is polymorphic and is the only part of the read path that knows about the type: for PAYME, SUBSCRIPTION and DONATION it is { amount, description, title } (amount is null for a donation, and title is only ever set there); for GOODS_SERVICES it is { items[], totalAmount, orderId }, where each item adds totalPrice and an imageUrl that is null when no image was attached. The order of the ownership checks is deliberate: an invalid id is a 400, a missing one a 404, someone else's a 403, and only then a soft-deleted one a 404 again — so a 403 does confirm that the id exists.
List invoice payments
/invoices/:id/paymentsAuth: JWT or API key signature
Pages through the invoice’s successful receipts. One row is one actual transfer, including partial ones. The invoice card already carries the first page.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| id | string (UUID) | Required | The invoice id, returned as businessInvoiceId. |
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| cursor | string | Optional | Position token taken from nextCursor. Omit it for the first page. | |
| limit | integer, 1–100 | Optional | 20 | Page size. |
/invoices/:id/paymentscurl -G 'https://dev-app.tedr.com/api/invoices/6f1c1e2c-9b4c-4a2e-9f8b-0d5a1b2c3d4e/payments' \
-H 'Authorization: Bearer YOUR_SESSION_JWT' \
-d 'limit=20'
const response = await fetch(
'https://dev-app.tedr.com/api/invoices/6f1c1e2c-9b4c-4a2e-9f8b-0d5a1b2c3d4e/payments?limit=20',
{ headers: { Authorization: 'Bearer YOUR_SESSION_JWT' } },
);
const { items, totalCount, nextCursor, totalBaseAmount, baseCurrency } =
await response.json();
import requests
response = requests.get(
'https://dev-app.tedr.com/api/invoices/6f1c1e2c-9b4c-4a2e-9f8b-0d5a1b2c3d4e/payments',
headers={'Authorization': 'Bearer YOUR_SESSION_JWT'},
params={'limit': 20},
)
page = response.json()
Response: 200
{
"items": [
{
"paymentId": "9e2f7a10-3c84-4f6b-8a21-5d0e7c9b1f43",
"status": "PAID",
"amount": "<transfer amount, in the currency it was paid in>",
"payerEmail": "buyer@example.com",
"txHash": "0x3f8a9c21d4b76e05f1a8c3d92b47e6081f5a2c9d3e7b4160a8d5c2f9b1e6a473",
"paidAt": "2026-07-31T08:20:11.000Z"
}
],
"totalCount": 3,
"nextCursor": "Qx7R2mK9tB4wL0sVhN6ZpA3cJ8dF1gY5",
"totalBaseAmount": "<sum of the receipts, in the base currency>",
"baseCurrency": "USD"
}
| Code | Status | When |
|---|---|---|
| VALIDATION_ERROR | 400 | The id is not a valid UUID, or the cursor is corrupted. |
| UNAUTHORIZED | 401 | The request carries no valid JWT. |
| FORBIDDEN | 403 | The invoice belongs to another merchant. |
| BUSINESS_INVOICE_NOT_FOUND | 404 | No invoice with this id exists, or it is already soft-deleted. |
Sorting is fixed — paidAt descending with id as a tiebreak; there are no filters and no sortOrder. A row is one receipt, so attempts in PARTIALLY_PAID, PAID_LATE and BLOCKED appear here too — that list is illustrative, not exhaustive. status is the status of the parent attempt and amount is that single transaction in the currency it was paid in, which is not your base currency: baseCurrency belongs to totalBaseAmount, the sum of every row converted for you. payerEmail is null on older attempts, and totalCount covers every receipt on the invoice, not just this page. The JSON shape of a money value is not pinned by this version of the reference — read it from the response rather than assuming a decimal string.
Cancel an invoice
/invoices/:id/cancelAuth: JWT or API key signature
Moves the invoice to CANCELED. Answers 200 with the same detail card as Get an invoice, not a new resource — which is why the status is 200 and not 201.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| id | string (UUID) | Required | The invoice id, returned as businessInvoiceId. |
/invoices/:id/cancelcurl -X POST 'https://dev-app.tedr.com/api/invoices/6f1c1e2c-9b4c-4a2e-9f8b-0d5a1b2c3d4e/cancel' \
-H 'Authorization: Bearer YOUR_SESSION_JWT'
const response = await fetch(
'https://dev-app.tedr.com/api/invoices/6f1c1e2c-9b4c-4a2e-9f8b-0d5a1b2c3d4e/cancel',
{
method: 'POST',
headers: { Authorization: 'Bearer YOUR_SESSION_JWT' },
},
);
const invoice = await response.json();
import requests
response = requests.post(
'https://dev-app.tedr.com/api/invoices/6f1c1e2c-9b4c-4a2e-9f8b-0d5a1b2c3d4e/cancel',
headers={'Authorization': 'Bearer YOUR_SESSION_JWT'},
)
invoice = response.json()
Response: 200
| Code | Status | When |
|---|---|---|
| VALIDATION_ERROR | 400 | The state machine forbids the transition — cancelling from EXPIRED, for instance — or a payment has already started on a type that does not allow it. |
| UNAUTHORIZED | 401 | The request carries no valid JWT. |
| FORBIDDEN | 403 | The invoice belongs to another merchant. |
| BUSINESS_INVOICE_NOT_FOUND | 404 | No invoice with this id exists, or it is already soft-deleted. |
Two checks run in order. The type's state machine allows the transition from ACTIVE but not from EXPIRED. On top of that, PAYME and GOODS_SERVICES refuse cancellation once a payment has started — an attempt in PROCESSING, PARTIALLY_PAID, BLOCKED, PAID, PAID_LATE or REFUNDED. A DONATION can be cancelled regardless of what it has received. Both refusals come back as VALIDATION_ERROR, not as a dedicated code. A SUBSCRIPTION plan follows its own rules: it can be cancelled from ACTIVE or PAUSED whatever its subscribers have paid, its subscribers are cancelled with it and unpaid period invoices are closed without a refund. A plan in any other state answers 409 BUSINESS_INVOICE_CANNOT_CANCEL, and a cancelled plan cannot be reactivated.
Reactivate an invoice
/invoices/:id/reactivateAuth: JWT or API key signature
Takes a cancelled invoice back to ACTIVE, provided its expiresAt is still in the future or null. Answers 200 with the same detail card as Get an invoice, not a new resource — which is why the status is 200 and not 201.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| id | string (UUID) | Required | The invoice id, returned as businessInvoiceId. |
/invoices/:id/reactivatecurl -X POST 'https://dev-app.tedr.com/api/invoices/6f1c1e2c-9b4c-4a2e-9f8b-0d5a1b2c3d4e/reactivate' \
-H 'Authorization: Bearer YOUR_SESSION_JWT'
const response = await fetch(
'https://dev-app.tedr.com/api/invoices/6f1c1e2c-9b4c-4a2e-9f8b-0d5a1b2c3d4e/reactivate',
{
method: 'POST',
headers: { Authorization: 'Bearer YOUR_SESSION_JWT' },
},
);
const invoice = await response.json();
import requests
response = requests.post(
'https://dev-app.tedr.com/api/invoices/6f1c1e2c-9b4c-4a2e-9f8b-0d5a1b2c3d4e/reactivate',
headers={'Authorization': 'Bearer YOUR_SESSION_JWT'},
)
invoice = response.json()
Response: 200
| Code | Status | When |
|---|---|---|
| VALIDATION_ERROR | 400 | The state machine forbids the transition, or the invoice has already expired. |
| UNAUTHORIZED | 401 | The request carries no valid JWT. |
| FORBIDDEN | 403 | The invoice belongs to another merchant. |
| BUSINESS_INVOICE_NOT_FOUND | 404 | No invoice with this id exists, or it is already soft-deleted. |
The deadline is checked separately from the state machine: the machine is a pure function of statuses and knows nothing about time, so without that extra check an expired invoice would come back to life pointing at a payment form that is already dead. An expired invoice is refused with VALIDATION_ERROR, like any other forbidden transition.
Delete an invoice
/invoices/:idAuth: JWT or API key signature
Soft-deletes the invoice: the row stays in the database with a deletion timestamp, but disappears from the list and from the payment form. Answers 200 with the same detail card as Get an invoice, not an empty 204.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| id | string (UUID) | Required | The invoice id, returned as businessInvoiceId. |
/invoices/:idcurl -X DELETE 'https://dev-app.tedr.com/api/invoices/6f1c1e2c-9b4c-4a2e-9f8b-0d5a1b2c3d4e' \
-H 'Authorization: Bearer YOUR_SESSION_JWT'
const response = await fetch(
'https://dev-app.tedr.com/api/invoices/6f1c1e2c-9b4c-4a2e-9f8b-0d5a1b2c3d4e',
{
method: 'DELETE',
headers: { Authorization: 'Bearer YOUR_SESSION_JWT' },
},
);
const invoice = await response.json();
import requests
response = requests.delete(
'https://dev-app.tedr.com/api/invoices/6f1c1e2c-9b4c-4a2e-9f8b-0d5a1b2c3d4e',
headers={'Authorization': 'Bearer YOUR_SESSION_JWT'},
)
invoice = response.json()
Response: 200
| Code | Status | When |
|---|---|---|
| VALIDATION_ERROR | 400 | The invoice type does not allow deletion. |
| BUSINESS_INVOICE_CANNOT_DELETE | 400 | The invoice already has payment activity — for a subscription plan, any payment attempt or receipt on any of its period invoices. |
| UNAUTHORIZED | 401 | The request carries no valid JWT. |
| FORBIDDEN | 403 | The invoice belongs to another merchant. |
| BUSINESS_INVOICE_NOT_FOUND | 404 | No invoice with this id exists, or it was already deleted — deleting twice answers 404. |
| SUBSCRIPTION_PLAN_NOT_CANCELED | 409 | The subscription plan has not been cancelled yet — cancel it first, then delete it. |
Two guards, and they fail with different codes. A type that forbids deletion is a plain VALIDATION_ERROR; payment activity gets the dedicated BUSINESS_INVOICE_CANNOT_DELETE. The payment check uses the same attempt statuses as cancellation, but here there is no per-type bypass — the rule holds for donations too. A SUBSCRIPTION plan is stricter: it must be CANCELED first, otherwise the answer is 409 SUBSCRIPTION_PLAN_NOT_CANCELED, and none of its period invoices may carry a payment attempt or receipt — even an attempt that never received any money blocks deletion with BUSINESS_INVOICE_CANNOT_DELETE. Deleting a plan removes its subscribers and period invoices along with it. Repeat deletes are idempotent as far as the caller can tell: the second one answers 404 BUSINESS_INVOICE_NOT_FOUND.
