API and webhooks
Create invoices from your own systems, collect the payments through PaidFinch, and get told as they are viewed, paid, refunded, or overdue. REST over HTTPS, JSON in and out, versioned at /v1. Part of the Pro plan.
Contract: openapi.yaml (OpenAPI 3.1). Keys and webhook endpoints live in Settings, Developers.
The basics
- Base URL:
https://app.paidfinch.com/api/v1. Staging:https://staging-app.paidfinch.com/api/v1, where Stripe runs in test mode so you can build with no real money. - Authenticate with
Authorization: Bearer pf_live_…. Keys startpf_live_in production andpf_test_on staging, and each environment refuses the other's. A key belongs to one business and carries scopes; a request outside them returns 403. - Money is integer cents. Dates are
YYYY-MM-DD. Timestamps are UTC with aZ. Ids are prefixed and unguessable:biz_,cli_,inv_,ins_,pay_,ref_,evt_. - Every create accepts an
Idempotency-Keyheader. The same key and body returns the original response for 24 hours; the same key with a different body returns 422. - Lists use cursor pagination:
limit(default 25, max 100) andstarting_after, answering{ "data": [...], "has_more": true, "next_cursor": "..." }. - Rate limit: 120 requests a minute per key, with
X-RateLimit-Limit,X-RateLimit-Remaining, andRetry-Afteron a 429. - Unknown fields return 422, so a typo never silently does nothing. Server to server only: CORS is off and a key must never reach a browser.
Errors
One shape, with a request id to quote to support:
{ "error": { "type": "invalid_request", "code": "parameter_invalid", "message": "unit_price_cents must be a positive integer", "param": "items.0.unit_price_cents", "request_id": "req_01J..." } }
Status codes: 400, 401, 403, 404, 409, 422, 429, 5xx. Types: invalid_request, authentication, forbidden, not_found, conflict, rate_limited, api_error.
Quickstart: a booking becomes an invoice
A customer accepts a quote in your booking system. Create the invoice with a deposit and a balance, let PaidFinch email it, and watch the webhooks.
POST /api/v1/invoices
Authorization: Bearer pf_test_...
Idempotency-Key: quote-1042-accepted
{
"external_source": "flourcity-booking",
"external_id": "quote_1042",
"client": { "external_id": "customer_377", "name": "Taylor and Jordan Reed", "email": "taylor@example.com" },
"issue_date": "2026-11-03",
"items": [
{ "description": "The Signature, 5 hours (wedding package)", "quantity": 1, "unit_price_cents": 69900 },
{ "description": "Additional rental hour", "quantity": 1, "unit_price_cents": 9900 }
],
"payment_plan": { "installments": [
{ "label": "Deposit to hold your date", "amount_cents": 20000, "due_date": "2026-11-03", "note": "Fully refundable until we confirm your booking." },
{ "label": "Balance", "amount_cents": null, "due_date": "2027-05-29" }
] },
"return_url": "https://flourcityphotobooth.com/bookings/1042",
"metadata": { "booking_id": "1042" },
"send": { "email": true, "message": "Thanks for choosing us. Your invoice and deposit link are below." }
}
The response is the invoice object, with pay_url, the numbered installments, and version. Retry the same request with the same Idempotency-Key and you get the same invoice. Send it again with a new key and you get 409 duplicate_external_id with existing_invoice_id, so a quote is never billed twice.
When the client pays the deposit you receive payment.succeeded and installment.paid; when the balance is paid, invoice.paid. Never trust the return_url redirect for payment status: use webhooks or GET /invoices/{id}.
Webhooks
Add an HTTPS endpoint in Settings, Developers, choose the event types (or all), and copy its signing secret. Every event carries the full current invoice, so you can treat events as upserts.
{
"id": "evt_01J...",
"type": "payment.succeeded",
"created": "2026-11-03T15:20:11Z",
"business_id": "biz_01J...",
"data": {
"payment": { "id": "pay_01J...", "amount_cents": 20000, "method": "stripe", "installment_id": "ins_01J...", "paid_at": "2026-11-03T15:20:09Z" },
"invoice": { "...the full invoice object..." }
}
}
Event types
| Type | When |
|---|---|
invoice.created | An invoice was created (through the API or in the app). |
invoice.sent | The invoice email went out, or was resent. |
invoice.viewed | The client opened the pay page for the first time. |
invoice.updated | The invoice changed after being finalized: an edit, a discount, a late fee, or credit applied. |
invoice.partially_paid | A payment landed but a balance remains. |
invoice.paid | The balance reached zero. |
invoice.overdue | The invoice first became overdue. |
invoice.voided | The invoice was voided, with a reason. |
payment.succeeded | A payment was received online or recorded by hand. data.payment carries it. |
payment.failed | An online or automatic payment failed. data.failure has the reason. |
payment.refunded | A payment was refunded (data.refund) or a manual payment reversed. |
installment.paid | One installment of a payment plan was settled. data.installment carries it. |
installment.overdue | One installment passed its due date unpaid. |
Signature
Header PaidFinch-Signature: t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256 of "{t}.{raw request body}" with your endpoint secret. Verify against the raw body and reject timestamps older than 5 minutes. During a secret rotation the header carries two v1 values for 24 hours.
// PHP example. Verify the raw body before parsing it.
$body = file_get_contents('php://input');
$header = $_SERVER['HTTP_PAIDFINCH_SIGNATURE'] ?? '';
parse_str(str_replace(',', '&', $header), $parts); // t=..., v1=...
$t = (int) ($parts['t'] ?? 0);
if (abs(time() - $t) > 300) { http_response_code(400); exit; } // too old
$expected = hash_hmac('sha256', "{$t}.{$body}", $secret);
$ok = false;
foreach (explode(',', $header) as $part) {
if (str_starts_with($part, 'v1=') && hash_equals($expected, substr($part, 3))) { $ok = true; }
}
if (! $ok) { http_response_code(400); exit; }
$event = json_decode($body, true);
// Dedupe on $event['id']; use $event['data']['invoice']['version'] to ignore stale events.
http_response_code(200);
Delivery
- At least once. Dedupe on the
evt_id. Ordering is not guaranteed, so compareinvoice.version. - A 2xx within 10 seconds is success. Anything else is retried: right away, then after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, and 24 hours.
- An endpoint that fails every delivery for 3 days is switched off and you are emailed. Re-enabling offers to resend what it missed.
- Redirects are not followed. Private and local addresses are refused.
GET /eventsreturns the same events for at least 30 days, for reconciliation.
Reference
Account
GET /me Who am I
The key's business, scopes, and environment. A quick connectivity check.
Responses: 200 Me 401 Error
Clients
GET /clients List clients scope clients:read
Parameters: limit (query), starting_after (query), external_id (query), external_source (query), email (query)
Responses: 200 list of Client
POST /clients Create or update a client scope clients:write
Matches an existing client by external_source and external_id, then by email; otherwise creates one.
Parameters: Idempotency-Key (header)
Body: ClientInput
GET /clients/{id} Get a client scope clients:read
Parameters: id (path, required)
Responses: 200 Client 404 Error
PATCH /clients/{id} Update a client scope clients:write
Parameters: id (path, required)
Body: ClientInput
Responses: 200 Client 404 Error 422 Error
Invoices
GET /invoices List invoices scope invoices:read
Parameters: limit (query), starting_after (query), status (query), external_id (query), external_source (query), client_id (query), created_after (query), updated_after (query)
Responses: 200 list of Invoice
POST /invoices Create an invoice scope invoices:write
The server computes every total. external_source plus external_id is unique per business: a second create for the same pair returns 409 duplicate_external_id with existing_invoice_id. Unknown fields return 422.
Parameters: Idempotency-Key (header)
Body: InvoiceInput
Responses: 201 Invoice 409 Error 422 Error
GET /invoices/{id} Get an invoice scope invoices:read
Parameters: id (path, required)
Responses: 200 Invoice 404 Error
PATCH /invoices/{id} Update an invoice scope invoices:write
A draft changes freely. An open invoice follows the editing rules: the total cannot drop below what has been paid, a revision is kept, and the payment plan is locked once a payment exists (409).
Parameters: id (path, required)
Body: InvoiceUpdate
Responses: 200 Invoice 404 Error 409 Error 422 Error
DELETE /invoices/{id} Delete a draft scope invoices:write
Parameters: id (path, required)
Responses: 200 object 409 Error
POST /invoices/{id}/send Send or resend the invoice email scope invoices:write
A draft is finalized and numbered first.
Parameters: id (path, required)
Body: object with message, subject, to
Responses: 200 Invoice 409 Error 422 Error
POST /invoices/{id}/void Void an invoice scope invoices:write
Parameters: id (path, required)
Body: object with reason
Responses: 200 Invoice 409 Error
GET /invoices/{id}/pdf Get a PDF link scope invoices:read
A signed URL that works for 15 minutes without a key.
Parameters: id (path, required)
Responses: 200 object
Payments
GET /invoices/{id}/payments List an invoice's payments scope payments:read
Parameters: id (path, required), limit (query), starting_after (query)
Responses: 200 list of Payment
POST /invoices/{id}/payments Record a manual payment scope payments:write
A check, cash, bank transfer, or similar. Never creates a Stripe charge.
Parameters: id (path, required), Idempotency-Key (header)
Body: ManualPaymentInput
Responses: 201 Payment 422 Error
GET /payments/{id} Get a payment scope payments:read
Parameters: id (path, required)
Responses: 200 Payment 404 Error
POST /payments/{id}/refunds Refund a payment scope refunds:write
For a Stripe payment, issues the refund on the connected account; the platform fee is refunded in proportion. A manual payment is reversed in full instead (object "reversal").
Parameters: id (path, required), Idempotency-Key (header)
Body: object with amount_cents, reason
Responses: 201 Refund or Reversal 409 Error 422 Error
Events
GET /events List events scope events:read
The same events webhooks deliver, kept for at least 30 days, so a consumer that missed webhooks can catch up.
Parameters: limit (query), starting_after (query), type (query), created_after (query)
Responses: 200 list of Event
GET /events/{id} Get an event scope events:read
Parameters: id (path, required)
Responses: 200 Event 404 Error
Objects
Error
| Field | Type | Notes |
|---|---|---|
error required | object |
Me
| Field | Type | Notes |
|---|---|---|
object required | string | |
environment required | string: live, test | |
business required | object | |
api_key required | object |
Address
| Field | Type | Notes |
|---|---|---|
line1 | string | null | |
line2 | string | null | |
city | string | null | |
state | string | null | |
zip | string | null |
ClientInput
| Field | Type | Notes |
|---|---|---|
external_source | string | |
external_id | string | |
name required | string | |
company_name | string | |
email | string (email) | |
cc_emails | array of string | |
phone | string | |
address | Address | |
notes | string | |
tax_exempt | boolean |
Client
| Field | Type | Notes |
|---|---|---|
id required | string | |
object required | string | |
name required | string | |
company_name | string | null | |
email required | string | null | |
cc_emails | array of string | |
phone | string | null | |
address | Address | |
notes | string | null | |
tax_exempt | boolean | |
external_source required | string | null | |
external_id required | string | null | |
archived | boolean | |
created_at required | string | null (date-time) | |
updated_at | string | null (date-time) |
Discount
| Field | Type | Notes |
|---|---|---|
type required | string: percent, flat | |
percent_bps | integer | Basis points: 1000 = 10.00%. |
amount_cents | integer | Integer cents. |
label | string |
ItemInput
| Field | Type | Notes |
|---|---|---|
title | string | Shown bold above the description. |
description | string | |
quantity | number | string | Up to 4 decimal places. Default 1. |
unit_price_cents required | integer | Integer cents. |
discount | Discount | |
tax_rate_id | integer |
InstallmentInput
| Field | Type | Notes |
|---|---|---|
label | string | |
amount_cents required | integer | null | null on at most one installment means the remainder. |
due_date required | string (date) | |
note | string | Plain text shown to the client. |
auto_charge | boolean | Pro; needs a saved card. |
InvoiceInput
| Field | Type | Notes |
|---|---|---|
external_source | string | |
external_id | string | |
client | ClientInput | |
client_id | string | Instead of a client object. |
issue_date | string (date) | |
due_date | string (date) | Default from the business terms; the last installment's date with a payment plan. |
reference | string | |
notes | string | |
terms_text | string | |
items required | array of ItemInput | |
discount | Discount | |
payment_plan | object | Amounts must add up to the total to the cent. |
allow_partial_payments | boolean | |
accept_online_payments | boolean | |
return_url | string | Where "Return to {Business}" sends the client after paying. Must be on an allowed domain. Never trust the redirect for payment status. |
metadata | object | Up to 50 keys (40 chars) with string values (500 chars). Returned unchanged, included in webhooks, never shown to the client. |
send | object | |
state | string: open, draft | open finalizes and numbers the invoice; draft leaves it for review. |
InvoiceUpdate
| Field | Type | Notes |
|---|---|---|
client_id | string | |
issue_date | string (date) | |
due_date | string (date) | |
reference | string | |
notes | string | |
terms_text | string | |
items | array of ItemInput | |
discount | Discount | |
payment_plan | object | null | |
allow_partial_payments | boolean | |
accept_online_payments | boolean | |
return_url | string | |
metadata | object |
Item
| Field | Type | Notes |
|---|---|---|
title | string | null | |
description required | string | |
quantity required | string | |
unit_price_cents required | integer | Integer cents. |
discount_cents | integer | Integer cents. |
tax_cents | integer | Integer cents. |
amount_cents required | integer | Integer cents. |
kind required | string: line, late_fee, security_deposit |
Installment
| Field | Type | Notes |
|---|---|---|
id required | string | |
sequence required | integer | |
label required | string | |
note | string | null | |
amount_cents required | integer | Integer cents. |
due_date required | string (date) | |
status required | string: scheduled, due, partially_paid, paid, overdue, canceled | |
paid_cents required | integer | Integer cents. |
paid_at | string | null (date-time) |
Invoice
| Field | Type | Notes |
|---|---|---|
id required | string | |
object required | string | |
version required | integer | Increases on every change; ignore events with a lower version than one you have seen. |
number required | string | null | null until finalized. |
status required | string: draft, open, paid, void, written_off | The stored lifecycle state. |
display_status required | string: draft, scheduled, sent, viewed, partially_paid, overdue, paid, void, written_off | The derived label. |
client_id required | string | null | |
client | object | null | |
currency required | string | |
issue_date required | string | null (date) | |
due_date required | string | null (date) | |
subtotal_cents | integer | Integer cents. |
discount_total_cents | integer | Integer cents. |
tax_total_cents | integer | Integer cents. |
total_cents required | integer | Integer cents. |
paid_cents required | integer | Integer cents. |
balance_due_cents required | integer | Integer cents. |
items required | array of Item | |
installments required | array of Installment | |
allow_partial_payments | boolean | |
accept_online_payments | boolean | |
notes | string | null | |
reference | string | null | |
pay_url required | string | |
external_source required | string | null | |
external_id required | string | null | |
metadata required | object | |
return_url | string | null | |
sent_at | string | null (date-time) | |
first_viewed_at | string | null (date-time) | |
paid_at | string | null (date-time) | |
voided_at | string | null (date-time) | |
void_reason | string | null | |
created_at required | string | null (date-time) | |
updated_at required | string | null (date-time) |
ManualPaymentInput
| Field | Type | Notes |
|---|---|---|
amount_cents required | integer | Integer cents. |
paid_on | string (date) | Default today in the business time zone. |
method required | string: check, cash, bank_transfer, zelle, venmo, other | |
reference | string | |
note | string | |
installment_id | string | Which installment this is for, if the invoice has a payment plan. |
send_receipt | boolean |
Payment
| Field | Type | Notes |
|---|---|---|
id required | string | |
object required | string | |
invoice_id required | string | null | |
installment_id | string | null | |
method required | string: stripe, manual | |
manual_method | string | null | |
status required | string: succeeded, failed, reversed, pending | |
amount_cents required | integer | Integer cents. |
platform_fee_cents | integer | Integer cents. |
refunded_cents | integer | Integer cents. |
reference | string | null | |
note | string | null | |
paid_at required | string | null (date-time) | |
created_at | string | null (date-time) |
Refund
| Field | Type | Notes |
|---|---|---|
id required | string | |
object required | string | |
payment_id required | string | null | |
amount_cents required | integer | Integer cents. |
status required | string | |
reason | string | null | |
created_at | string | null (date-time) |
Reversal
| Field | Type | Notes |
|---|---|---|
object required | string: reversal | |
payment required | Payment |
Event
| Field | Type | Notes |
|---|---|---|
id required | string | |
object | string | |
type required | string: invoice.created, invoice.sent, invoice.viewed, invoice.updated, invoice.partially_paid, invoice.paid, invoice.overdue, invoice.voided, payment.succeeded, payment.failed, payment.refunded, installment.paid, installment.overdue | |
created required | string (date-time) | |
business_id required | string | null | |
data required | object |