Invoices
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.
The invoice object
Section titled “The invoice object”{ "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"}| Field | Type | Description |
|---|---|---|
id | string | Unique invoice ID |
public_url | string | null | Customer 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_number | string|null | Human-readable invoice number (e.g. "INV-5831") |
ticket_id | string|null | The ticket this invoice is attached to, or null for standalone |
status | string | draft, sent, pending, partial, paid, voided, or refunded |
currency | string | ISO 4217 currency code (e.g. "USD") |
customer | object | Denormalized customer contact (name, email, phone) |
line_items | array | Line items; each has name, quantity, unit_price, amount |
subtotal | Money | Pre-discount, pre-tax total |
discount | Money | Invoice-level discount amount |
tax_rate | number|null | Tax rate as a percent (e.g. 8.25) |
tax_amount | Money | Computed tax amount |
total | Money | Final total (subtotal − discount + tax) |
deposit | Money | Deposit collected at ticket creation |
amount_paid | Money | Total payments applied to this invoice |
balance_due | Money | Remaining balance (total − amount_paid) |
refunded_amount | Money | Amount refunded so far |
payments | object | Reference to the payments list; fetch with the provided url |
po_number | string|null | Purchase order number |
location_id | string|null | Sale location ID (multi-location tenants), or null |
attributed_to | string|null | Team member credited with the sale, or null |
sent_via | string|null | Delivery channel last used (sms, email, both) |
payment_method | string|null | How the invoice was paid |
paid_at | string|null | ISO-8601 timestamp when the invoice reached paid |
created_at | string | ISO-8601 creation timestamp |
All Money objects have amount_cents (integer), amount (decimal string), and currency.
List invoices
Section titled “List invoices”GET /api/v1/invoicesReturns a cursor-paginated list of invoices, newest first.
Scope required: invoices.read
Query parameters
Section titled “Query parameters”| Parameter | Type | Description |
|---|---|---|
status | string | Filter by status (draft, sent, pending, partial, paid, voided, refunded) |
ticket_id | string | Filter to a specific ticket |
customer_email | string | Case-insensitive exact match on customer email |
location_id | string | Filter to one sale location |
created_after | string | ISO-8601, only invoices created at or after this time |
created_before | string | ISO-8601, only invoices created at or before this time |
limit | integer | Page size, 1–100 (default: 25) |
cursor | string | Opaque cursor from a previous response’s next_cursor |
curl "https://app.benchkey.com/api/v1/invoices?status=paid&limit=10" \ -H "Authorization: Bearer bk_live_01J8X4_kvWz9nP3mRqTs7uYeBfGhDjLa"Node.js
Section titled “Node.js”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();Response
Section titled “Response”{ "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.
Retrieve an invoice
Section titled “Retrieve an invoice”GET /api/v1/invoices/:idReturns a single invoice with full line items, totals, and a link to its payments.
Scope required: invoices.read
curl "https://app.benchkey.com/api/v1/invoices/5831" \ -H "Authorization: Bearer bk_live_01J8X4_kvWz9nP3mRqTs7uYeBfGhDjLa"Node.js
Section titled “Node.js”const res = await fetch( "https://app.benchkey.com/api/v1/invoices/5831", { headers: { Authorization: "Bearer bk_live_01J8X4_kvWz9nP3mRqTs7uYeBfGhDjLa" } });const invoice = await res.json();Response
Section titled “Response”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).
Create an invoice
Section titled “Create an invoice”POST /api/v1/invoicesCreates 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.
Request body
Section titled “Request body”| Field | Required | Description |
|---|---|---|
customer_id | yes | The customer to bill |
ticket_id | no | Existing ticket to attach the invoice to; omit to create a billing-only ticket |
line_items | no | Array of line items (see below). Omit to create an empty draft |
discount_cents | no | Invoice-level discount in integer cents. Requires at least one line item |
tax_rate | no | Tax rate as a percent (e.g. 8.25). Requires at least one line item |
Line item fields (each element of line_items):
| Field | Required | Description |
|---|---|---|
description | yes | Line item label (alias: name). Must be a string |
unit_price_cents | yes | Unit price in integer cents (positive safe integer) |
quantity | no | Quantity (default: 1). Alias qty is also accepted. unit_price_cents × quantity must produce a whole number of cents |
cost_cents | no | Unit 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).
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 }'Node.js
Section titled “Node.js”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 201Response
Section titled “Response”Returns the invoice object with HTTP 201.
Edit an invoice
Section titled “Edit an invoice”PATCH /api/v1/invoices/:idReprices 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
Request body
Section titled “Request body”| Field | Required | Type | Description |
|---|---|---|---|
line_items | yes | array | Complete replacement set, same line item fields as create, at least one item |
discount_cents | no | integer | Invoice-level discount in integer cents. Must be less than the line-item subtotal |
tax_rate | no | number | Tax rate as a percent (0–100). Omit to keep the stored rate |
deposit_cents | no | integer | Deposit credited against the invoice, in integer cents. Omit to keep the stored deposit |
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 }'Response
Section titled “Response”Returns the updated invoice object with HTTP 200, totals and line items reflect the persisted result.
Delete an invoice
Section titled “Delete an invoice”DELETE /api/v1/invoices/:idDeletes 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
curl -X DELETE https://app.benchkey.com/api/v1/invoices/88 \ -H "Authorization: Bearer bk_live_01J8X4_kvWz9nP3mRqTs7uYeBfGhDjLa"Response
Section titled “Response”{ "object": "invoice", "id": "88", "deleted": true }Send an invoice
Section titled “Send an invoice”POST /api/v1/invoices/:id/sendDelivers 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.
Request body
Section titled “Request body”| Field | Required | Description |
|---|---|---|
via | yes | Delivery channel: sms, email, or both |
email | no | Recipient email override (must be a string; non-string values return 400 invalid_field) |
phone | no | Recipient phone override (must be a string; non-string values return 400 invalid_field) |
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" }'Node.js
Section titled “Node.js”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();Response
Section titled “Response”Returns the refreshed invoice object.
Remind on an invoice
Section titled “Remind on an invoice”POST /api/v1/invoices/:id/remindResends 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.
Request body
Section titled “Request body”| Field | Required | Description |
|---|---|---|
via | no | Delivery channel: sms, email, or both (default: sms) |
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" }'Node.js
Section titled “Node.js”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();Response
Section titled “Response”Returns the refreshed invoice object.
Void an invoice
Section titled “Void an invoice”POST /api/v1/invoices/:id/voidVoids 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.
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)"Node.js
Section titled “Node.js”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();Response
Section titled “Response”Returns the refreshed invoice object with status: "voided".
Refund an invoice
Section titled “Refund an invoice”POST /api/v1/invoices/:id/refundRefunds 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.
Request body
Section titled “Request body”| Field | Required | Description |
|---|---|---|
obligation_disposition | yes | What 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 |
reason | yes | Machine-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 |
note | no | Optional free-text note recorded with the refund decision, at most 1000 characters |
amount_cents | no | Amount to refund in integer cents. Omit for a full refund. Must not exceed the refundable balance |
method | no | How to deliver the refund: square, stripe, affirm, cash, check, zelle, or other. Defaults to the original payment method |
reference | no | Optional 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.
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)fiBENCHKEY_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" }'Node.js
Section titled “Node.js”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();Refund response
Section titled “Refund response”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"}Error codes
Section titled “Error codes”| HTTP status | Code | Meaning |
|---|---|---|
400 | invalid_query | A list filter parameter was supplied as an array or object instead of a single scalar value |
400 | missing_field | A required field is absent (customer_id, via) |
400 | invalid_field | A 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 |
400 | invalid_body | The request body is not a JSON object, or a void request carried fields (void takes no body) |
400 | unknown_field | The request body contains a field this endpoint does not accept |
400 | invalid_id | The :id path segment is not a valid positive integer |
400 | create_failed | Invoice creation was rejected by the internal route |
400 | send_failed | The invoice could not be sent (e.g. already voided) |
400 | remind_failed | The invoice is not in the sent state |
400 | void_failed | Only draft, sent, or partial invoices can be voided |
400 | refund_intent_token_required | A refund was sent without an Idempotency-Key header |
400 | obligation_disposition_required | A refund omitted obligation_disposition |
400 | obligation_disposition_invalid | A refund’s obligation_disposition is not keep_open or close |
400 | obligation_closure_reason_required | A refund omitted reason |
400 | obligation_closure_reason_invalid | A refund’s reason is not a machine-readable code (lowercase letters and digits joined by _, -, or :, starting with a letter, at most 80 characters) |
400 | refund_failed | Refund rejected, not paid/partial, invalid amount, or method mismatch. A more specific code is returned instead when BenchKey has one |
400 | ambiguous_refund_method | Invoice was paid with multiple tenders; specify method so the refund hits the intended rail |
404 | not_found | No invoice with that ID exists for this tenant |
409 | invoice_void_has_payments | The invoice still holds collected money that has not been refunded; refund it before voiding |
409 | void_conflict | The 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 |
409 | refund_conflict | The 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) |
422 | void_failed | The invoice is not attached to a ticket, or the void could not be completed |
422 | nothing_to_refund | Invoice has no remaining refundable balance |
422 | store_credit_refund_unsupported | Invoice 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 |
422 | refund_failed | The refund failed for a reason other than a business rule, for example a provider error |
429 | rate_limited | Send/remind/refund rate limit exceeded |
503 | invoice_authority_unavailable | The invoice could not be read safely, so nothing was changed. Retry after the Retry-After delay |
503 | invoice_payment_authority_unavailable | The invoice’s payment history could not be read, so no refund was attempted. Retry after the Retry-After delay |
403 | insufficient_scope | API key lacks invoices.read or invoices.write |
See Errors for the full error envelope format.