Pular para o conteúdo

Invoices

Este conteúdo não está disponível em sua língua ainda.

Invoices are the billing documents BenchKey sends to customers. Each invoice records line items, totals (subtotal, discount, tax, total), payments collected, and the remaining balance due. All money values are returned as integer cents alongside a formatted string and a currency code.

{
"object": "invoice",
"id": "5831",
"public_url": "https://app.benchkey.com/invoice/5831/tok_9f2c81d4",
"invoice_number": "INV-5831",
"ticket_id": "10042",
"status": "paid",
"currency": "USD",
"customer": {
"name": "Jane Smith",
"email": "jane.smith@example.com",
"phone": "555-867-5309"
},
"line_items": [
{
"object": "invoice_line_item",
"name": "Screen replacement",
"quantity": 1,
"unit_price": { "amount_cents": 12999, "amount": "129.99", "currency": "USD" },
"amount": { "amount_cents": 12999, "amount": "129.99", "currency": "USD" }
}
],
"subtotal": { "amount_cents": 12999, "amount": "129.99", "currency": "USD" },
"discount": { "amount_cents": 0, "amount": "0.00", "currency": "USD" },
"tax_rate": 8.25,
"tax_amount": { "amount_cents": 1072, "amount": "10.72", "currency": "USD" },
"total": { "amount_cents": 14071, "amount": "140.71", "currency": "USD" },
"deposit": { "amount_cents": 0, "amount": "0.00", "currency": "USD" },
"amount_paid": { "amount_cents": 14071, "amount": "140.71", "currency": "USD" },
"balance_due": { "amount_cents": 0, "amount": "0.00", "currency": "USD" },
"refunded_amount": { "amount_cents": 0, "amount": "0.00", "currency": "USD" },
"payments": {
"object": "list",
"url": "/api/v1/payments?invoice_id=5831"
},
"po_number": null,
"location_id": "1",
"attributed_to": "alex@repairshop.com",
"sent_via": "sms",
"payment_method": "square",
"paid_at": "2025-11-03T18:02:11.000Z",
"created_at": "2025-11-01T10:14:22.000Z"
}
FieldTypeDescription
idstringUnique invoice ID
public_urlstring | nullCustomer share link, the same tokenized page BenchKey emails/texts. Anyone with the URL can view and pay the invoice; treat it like the emailed link. null until the invoice has a share token
invoice_numberstring|nullHuman-readable invoice number (e.g. "INV-5831")
ticket_idstring|nullThe ticket this invoice is attached to, or null for standalone
statusstringdraft, sent, pending, partial, paid, voided, or refunded
currencystringISO 4217 currency code (e.g. "USD")
customerobjectDenormalized customer contact (name, email, phone)
line_itemsarrayLine items; each has name, quantity, unit_price, amount
subtotalMoneyPre-discount, pre-tax total
discountMoneyInvoice-level discount amount
tax_ratenumber|nullTax rate as a percent (e.g. 8.25)
tax_amountMoneyComputed tax amount
totalMoneyFinal total (subtotal − discount + tax)
depositMoneyDeposit collected at ticket creation
amount_paidMoneyTotal payments applied to this invoice
balance_dueMoneyRemaining balance (total − amount_paid)
refunded_amountMoneyAmount refunded so far
paymentsobjectReference to the payments list; fetch with the provided url
po_numberstring|nullPurchase order number
location_idstring|nullSale location ID (multi-location tenants), or null
attributed_tostring|nullTeam member credited with the sale, or null
sent_viastring|nullDelivery channel last used (sms, email, both)
payment_methodstring|nullHow the invoice was paid
paid_atstring|nullISO-8601 timestamp when the invoice reached paid
created_atstringISO-8601 creation timestamp

All Money objects have amount_cents (integer), amount (decimal string), and currency.


GET /api/v1/invoices

Returns a cursor-paginated list of invoices, newest first.

Scope required: invoices.read

ParameterTypeDescription
statusstringFilter by status (draft, sent, pending, partial, paid, voided, refunded)
ticket_idstringFilter to a specific ticket
customer_emailstringCase-insensitive exact match on customer email
location_idstringFilter to one sale location
created_afterstringISO-8601, only invoices created at or after this time
created_beforestringISO-8601, only invoices created at or before this time
limitintegerPage size, 1–100 (default: 25)
cursorstringOpaque cursor from a previous response’s next_cursor
Terminal window
curl "https://app.benchkey.com/api/v1/invoices?status=paid&limit=10" \
-H "Authorization: Bearer bk_live_01J8X4_kvWz9nP3mRqTs7uYeBfGhDjLa"
const params = new URLSearchParams({ status: "paid", limit: "10" });
const res = await fetch(
`https://app.benchkey.com/api/v1/invoices?${params}`,
{ headers: { Authorization: "Bearer bk_live_01J8X4_kvWz9nP3mRqTs7uYeBfGhDjLa" } }
);
const { data, has_more, next_cursor } = await res.json();
{
"object": "list",
"data": [ { "object": "invoice", "id": "5831", "status": "paid" } ],
"has_more": false,
"next_cursor": null
}

Additional resource fields are omitted from this example.

See Pagination for how to page through results.


GET /api/v1/invoices/:id

Returns a single invoice with full line items, totals, and a link to its payments.

Scope required: invoices.read

Terminal window
curl "https://app.benchkey.com/api/v1/invoices/5831" \
-H "Authorization: Bearer bk_live_01J8X4_kvWz9nP3mRqTs7uYeBfGhDjLa"
const res = await fetch(
"https://app.benchkey.com/api/v1/invoices/5831",
{ headers: { Authorization: "Bearer bk_live_01J8X4_kvWz9nP3mRqTs7uYeBfGhDjLa" } }
);
const invoice = await res.json();

Returns the invoice object. Returns 404 if no invoice with that ID exists for this tenant, or if the invoice is linked to a hidden or soft-deleted ticket (matching the list behavior).


POST /api/v1/invoices

Creates a new draft invoice for a customer. A public view token is minted so the invoice can be shared. When ticket_id is omitted a billing-only ticket is created automatically. Returns 201 on success with a Location header pointing to the new invoice.

Scope required: invoices.write

Send an Idempotency-Key header to make retries safe. See Idempotency.

FieldRequiredDescription
customer_idyesThe customer to bill
ticket_idnoExisting ticket to attach the invoice to; omit to create a billing-only ticket
line_itemsnoArray of line items (see below). Omit to create an empty draft
discount_centsnoInvoice-level discount in integer cents. Requires at least one line item
tax_ratenoTax rate as a percent (e.g. 8.25). Requires at least one line item

Line item fields (each element of line_items):

FieldRequiredDescription
descriptionyesLine item label (alias: name). Must be a string
unit_price_centsyesUnit price in integer cents (positive safe integer)
quantitynoQuantity (default: 1). Alias qty is also accepted. unit_price_cents × quantity must produce a whole number of cents
cost_centsnoUnit cost in integer cents, for margin reporting

The server enforces that each line’s extended price, the aggregate line total (sum of unit_price_cents × quantity across all items), and, when tax_rate is supplied, the resulting tax amount and final total all fit within JavaScript’s safe-integer range. If any of these would overflow, the request is rejected with 400 invalid_field on the offending field (unit_price_cents for a single line, tax_rate when the tax-pushed total overflows).

Terminal window
curl -X POST https://app.benchkey.com/api/v1/invoices \
-H "Authorization: Bearer bk_live_01J8X4_kvWz9nP3mRqTs7uYeBfGhDjLa" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: create-inv-$(uuidgen)" \
-d '{
"customer_id": "42",
"ticket_id": "10042",
"line_items": [
{ "description": "Screen replacement", "unit_price_cents": 12999, "quantity": 1 },
{ "description": "Labor", "unit_price_cents": 5000, "quantity": 1 }
],
"tax_rate": 8.25
}'
const res = await fetch("https://app.benchkey.com/api/v1/invoices", {
method: "POST",
headers: {
Authorization: "Bearer bk_live_01J8X4_kvWz9nP3mRqTs7uYeBfGhDjLa",
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({
customer_id: "42",
ticket_id: "10042",
line_items: [
{ description: "Screen replacement", unit_price_cents: 12999, quantity: 1 },
{ description: "Labor", unit_price_cents: 5000, quantity: 1 },
],
tax_rate: 8.25,
}),
});
const invoice = await res.json(); // HTTP 201

Returns the invoice object with HTTP 201.


PATCH /api/v1/invoices/:id

Reprices an invoice. This is a full replacement of the line items, send the complete set; discount, tax, and total are recomputed by the same internal edit flow staff use (an outstanding Square payment link is voided and recreated, and consumed inventory is delta-reconciled).

Editable statuses are draft and sent. Money-state protections answer 409:

  • Paid / partially paid / refunded (409 invoice_financials_locked), money has been collected; the pricing is locked. Refund it or create a new invoice.
  • Voided (409 voided_invoice_locked), voided is terminal.
  • A payment landing mid-edit surfaces as 409 invoice_edit_conflict, re-read and retry.

Scope required: invoices.write

FieldRequiredTypeDescription
line_itemsyesarrayComplete replacement set, same line item fields as create, at least one item
discount_centsnointegerInvoice-level discount in integer cents. Must be less than the line-item subtotal
tax_ratenonumberTax rate as a percent (0–100). Omit to keep the stored rate
deposit_centsnointegerDeposit credited against the invoice, in integer cents. Omit to keep the stored deposit
Terminal window
curl -X PATCH https://app.benchkey.com/api/v1/invoices/88 \
-H "Authorization: Bearer bk_live_01J8X4_kvWz9nP3mRqTs7uYeBfGhDjLa" \
-H "Content-Type: application/json" \
-d '{
"line_items": [
{ "description": "Screen replacement", "unit_price_cents": 13999 },
{ "description": "Labor", "unit_price_cents": 4500 }
],
"tax_rate": 8.25
}'

Returns the updated invoice object with HTTP 200, totals and line items reflect the persisted result.


DELETE /api/v1/invoices/:id

Deletes a draft or voided invoice. Outstanding payment artifacts are cancelled (Stripe checkout session expired, Square invoice cancelled) and the invoice’s event and payment history is cleaned up with it.

Any other status, or an invoice with recorded payments, answers 409 (invoice_delete_status / invoice_delete_has_payments): paid invoices keep their payment history; void or refund first.

Scope required: invoices.write

Terminal window
curl -X DELETE https://app.benchkey.com/api/v1/invoices/88 \
-H "Authorization: Bearer bk_live_01J8X4_kvWz9nP3mRqTs7uYeBfGhDjLa"
{ "object": "invoice", "id": "88", "deleted": true }

POST /api/v1/invoices/:id/send

Delivers the invoice to the customer via SMS, email, or both. For invoices without an existing payment link, a Square payment link is generated on the fly. Stamps sent_via on the invoice and returns the refreshed invoice.

Supply email or phone to override the recipient when the invoice has no contact on file (e.g. standalone walk-in invoices).

Scope required: invoices.write

Send an Idempotency-Key header to make retries safe.

FieldRequiredDescription
viayesDelivery channel: sms, email, or both
emailnoRecipient email override (must be a string; non-string values return 400 invalid_field)
phonenoRecipient phone override (must be a string; non-string values return 400 invalid_field)
Terminal window
curl -X POST "https://app.benchkey.com/api/v1/invoices/5831/send" \
-H "Authorization: Bearer bk_live_01J8X4_kvWz9nP3mRqTs7uYeBfGhDjLa" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: send-5831-$(uuidgen)" \
-d '{ "via": "sms" }'
const res = await fetch("https://app.benchkey.com/api/v1/invoices/5831/send", {
method: "POST",
headers: {
Authorization: "Bearer bk_live_01J8X4_kvWz9nP3mRqTs7uYeBfGhDjLa",
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({ via: "sms" }),
});
const invoice = await res.json();

Returns the refreshed invoice object.


POST /api/v1/invoices/:id/remind

Resends the payment link for an already-sent invoice to nudge the customer to pay. Only invoices in the sent state can be reminded (returns 400 otherwise). Logs a resent event. Defaults to SMS; pass via to choose the channel.

Scope required: invoices.write

Send an Idempotency-Key header to make retries safe.

FieldRequiredDescription
vianoDelivery channel: sms, email, or both (default: sms)
Terminal window
curl -X POST "https://app.benchkey.com/api/v1/invoices/5831/remind" \
-H "Authorization: Bearer bk_live_01J8X4_kvWz9nP3mRqTs7uYeBfGhDjLa" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: remind-5831-$(uuidgen)" \
-d '{ "via": "email" }'
const res = await fetch("https://app.benchkey.com/api/v1/invoices/5831/remind", {
method: "POST",
headers: {
Authorization: "Bearer bk_live_01J8X4_kvWz9nP3mRqTs7uYeBfGhDjLa",
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({ via: "email" }),
});
const invoice = await res.json();

Returns the refreshed invoice object.


POST /api/v1/invoices/:id/void

Voids a draft, sent, or partial invoice. Cancels the associated Square invoice or payment link, restores any consumed inventory, and logs the event. An invoice that still holds collected money (payments that have not been refunded) cannot be voided: refund it first. That refusal returns 409 invoice_void_has_payments. Send no body, or an empty JSON object. Returns the refreshed invoice with status voided.

Scope required: invoices.write

Send an Idempotency-Key header to make retries safe.

Terminal window
curl -X POST "https://app.benchkey.com/api/v1/invoices/5831/void" \
-H "Authorization: Bearer bk_live_01J8X4_kvWz9nP3mRqTs7uYeBfGhDjLa" \
-H "Idempotency-Key: void-5831-$(uuidgen)"
const res = await fetch("https://app.benchkey.com/api/v1/invoices/5831/void", {
method: "POST",
headers: {
Authorization: "Bearer bk_live_01J8X4_kvWz9nP3mRqTs7uYeBfGhDjLa",
"Idempotency-Key": crypto.randomUUID(),
},
});
const invoice = await res.json();

Returns the refreshed invoice object with status: "voided".


POST /api/v1/invoices/:id/refund

Refunds a paid or partial invoice. Omit amount_cents for a full refund of the remaining refundable balance, or supply it for a partial refund. For invoices paid through Square, Stripe, or Affirm the matching provider refund is issued; otherwise the refund is recorded against the chosen manual method. Updates refunded_amount, the register, revenue, and commission rows atomically. Returns the refreshed invoice.

Every refund records the same decision staff make in the BenchKey app: what happens to any unpaid invoice balance afterwards (obligation_disposition) and why (reason, a machine-readable code). Put free-text context in note.

Scope required: invoices.write

An Idempotency-Key is required; without one the request returns 400 refund_intent_token_required. Replaying the same key with the same body returns the original result without refunding twice. Reuse it only for an identical retry.

Generate and save an operation key before the first request. The examples keep it in refund-invoice-5831.key and read the same file after a timeout or process restart. Keep the request unchanged and retain the file with the result. Use a different file and key for a separate operation. Completed responses are normally replayable for 24 hours; after that, check saved results and the resource state before resubmitting.

FieldRequiredDescription
obligation_dispositionyesWhat happens to any unpaid invoice balance after the refund. keep_open: the customer still owes it, and the invoice stays open with a balance due. close: the unpaid balance is closed and no longer owed
reasonyesMachine-readable reason code: lowercase letters and digits, words joined by _, -, or :, starting with a letter, at most 80 characters. Uppercase is accepted and lowercased. The BenchKey app uses customer_request, billing_correction, service_resolution, duplicate_charge, and other; any code in this format is accepted
notenoOptional free-text note recorded with the refund decision, at most 1000 characters
amount_centsnoAmount to refund in integer cents. Omit for a full refund. Must not exceed the refundable balance
methodnoHow to deliver the refund: square, stripe, affirm, cash, check, zelle, or other. Defaults to the original payment method
referencenoOptional external reference (check number, processor reference, …). Must be a string if provided, non-string values return 400 invalid_field

Any other field returns 400 unknown_field.

Terminal window
set -e
# Keep this file for this operation, including across process restarts.
if [ ! -e refund-invoice-5831.key ]; then
(umask 077; set -C; uuidgen > refund-invoice-5831.key)
fi
BENCHKEY_OPERATION_KEY=$(cat refund-invoice-5831.key)
: "${BENCHKEY_OPERATION_KEY:?The saved operation key is empty}"
curl -X POST "https://app.benchkey.com/api/v1/invoices/5831/refund" \
-H "Authorization: Bearer bk_live_01J8X4_kvWz9nP3mRqTs7uYeBfGhDjLa" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $BENCHKEY_OPERATION_KEY" \
-d '{
"amount_cents": 5000,
"method": "square",
"obligation_disposition": "close",
"reason": "customer_request",
"note": "Customer not satisfied with repair"
}'
import { randomUUID } from "node:crypto";
import { readFileSync, writeFileSync } from "node:fs";
// Create once; read the existing key after a timeout or process restart.
const keyFile = "refund-invoice-5831.key";
try {
writeFileSync(keyFile, randomUUID(), { flag: "wx", mode: 0o600 });
} catch (error) {
if (error.code !== "EEXIST") throw error;
}
const refundKey = readFileSync(keyFile, "utf8").trim();
if (!refundKey) throw new Error("The saved operation key is empty");
const res = await fetch("https://app.benchkey.com/api/v1/invoices/5831/refund", {
method: "POST",
headers: {
Authorization: "Bearer bk_live_01J8X4_kvWz9nP3mRqTs7uYeBfGhDjLa",
"Content-Type": "application/json",
"Idempotency-Key": refundKey,
},
body: JSON.stringify({
amount_cents: 5000,
method: "square",
obligation_disposition: "close",
reason: "customer_request",
note: "Customer not satisfied with repair",
}),
});
const invoice = await res.json();

HTTP 200, applied immediately: Returns the refreshed invoice object with updated refunded_amount and balance_due.

HTTP 202, refund outcome still needs handling: A typed refund_outcome can report pending, needs_review, or outcome_unknown. The HTTP status alone does not prove that money was returned. settled is true only when an applied settlement entry exists; requested_amount is the request, while amount is present only for settled money. Keep the original idempotency key and read back the invoice and outcome before marking a refund complete.

The legacy refund_reconciliation response below specifically means provider-applied money is not yet reflected locally. It retains local_state_applied: false; that field is not part of the typed refund_outcome shape.

{
"object": "refund_reconciliation",
"code": "refund_state_changed",
"invoice_id": "5831",
"local_state_applied": false,
"message": "The provider refund was accepted, but BenchKey could not update the local invoice ledger...",
"amount": { "amount_cents": 5000, "amount": "50.00", "currency": "USD" },
"currency": "USD",
"full_refund": false,
"method": "square",
"square_refund_id": "sq_ref_abc123"
}

HTTP statusCodeMeaning
400invalid_queryA list filter parameter was supplied as an array or object instead of a single scalar value
400missing_fieldA required field is absent (customer_id, via)
400invalid_fieldA field value is invalid, non-integer cents, unknown channel, malformed date, customer_id/ticket_id as array/object, description not a string, unit_price_cents × quantity not a whole number of cents, a single line’s extended price, the aggregate line total, or the tax-pushed final total exceeds the safe-integer range; on refund, amount_cents not a positive integer, method not one of the refund methods, reason/note/reference not a string, or note/reference over its length limit; email/phone send-override not a string
400invalid_bodyThe request body is not a JSON object, or a void request carried fields (void takes no body)
400unknown_fieldThe request body contains a field this endpoint does not accept
400invalid_idThe :id path segment is not a valid positive integer
400create_failedInvoice creation was rejected by the internal route
400send_failedThe invoice could not be sent (e.g. already voided)
400remind_failedThe invoice is not in the sent state
400void_failedOnly draft, sent, or partial invoices can be voided
400refund_intent_token_requiredA refund was sent without an Idempotency-Key header
400obligation_disposition_requiredA refund omitted obligation_disposition
400obligation_disposition_invalidA refund’s obligation_disposition is not keep_open or close
400obligation_closure_reason_requiredA refund omitted reason
400obligation_closure_reason_invalidA refund’s reason is not a machine-readable code (lowercase letters and digits joined by _, -, or :, starting with a letter, at most 80 characters)
400refund_failedRefund rejected, not paid/partial, invalid amount, or method mismatch. A more specific code is returned instead when BenchKey has one
400ambiguous_refund_methodInvoice was paid with multiple tenders; specify method so the refund hits the intended rail
404not_foundNo invoice with that ID exists for this tenant
409invoice_void_has_paymentsThe invoice still holds collected money that has not been refunded; refund it before voiding
409void_conflictThe invoice cannot be voided in its current state. A more specific code is returned instead when BenchKey has one, for example payment_attempt_open while a provider payment attempt is still open
409refund_conflictThe refund conflicts with the invoice’s current refund state. A more specific code is returned instead when BenchKey has one, for example refund_in_progress (this refund is still being processed), refund_balance_changed (the refundable balance changed; review the invoice and submit a new refund), or manual_refund_duplicate_recent (the same cash, check, Zelle, or other refund, with the same amount, reason, note, and reference, was recorded in the last 30 seconds under a different Idempotency-Key; review the invoice before sending it again)
422void_failedThe invoice is not attached to a ticket, or the void could not be completed
422nothing_to_refundInvoice has no remaining refundable balance
422store_credit_refund_unsupportedInvoice was paid (in whole or part) with store credit; refunding store-credit money via the API is not yet supported, reverse the store-credit portion in the app
422refund_failedThe refund failed for a reason other than a business rule, for example a provider error
429rate_limitedSend/remind/refund rate limit exceeded
503invoice_authority_unavailableThe invoice could not be read safely, so nothing was changed. Retry after the Retry-After delay
503invoice_payment_authority_unavailableThe invoice’s payment history could not be read, so no refund was attempted. Retry after the Retry-After delay
403insufficient_scopeAPI key lacks invoices.read or invoices.write

See Errors for the full error envelope format.

Status do sistema