Invoice Postbacks
Invoice postbacks are outbound webhooks PayConnect sends to your endpoint when an invoice changes status — created, paid, voided, refunded, or overdue. They are the invoice-terminal counterpart to Client Postbacks (which fire on a transaction outcome): the two are independent, and a paid invoice can produce both.
Every delivery is HMAC-signed so you can verify it came from PayConnect and wasn't altered.
How It Works
- An invoice changes status (e.g. a customer pays it).
- PayConnect signs a JSON payload and sends an HTTP
POSTto your configured URL. - Your endpoint verifies the signature, processes the event, and returns a
2xx. - A
5xx, timeout, or connection error is retried with exponential backoff (~8 attempts over ~1 hour) before being parked. A4xx(other than408/429) is treated as a permanent rejection and is not retried — see Retries.
Configuration
Invoice postbacks are configured per company (URL, subscribed event types, optional endpoint auth). Contact your PayConnect account representative or email [email protected] to enable them. On first setup you'll be given a signing secret — store it securely; it's shown only once and is required to verify signatures.
Event Types
type | Fires when |
|---|---|
invoice.created | An invoice is created |
invoice.paid | An invoice is paid in full |
invoice.partial | A partial payment is received and a balance remains |
invoice.voided | An invoice is voided |
invoice.refunded | An invoice is refunded |
invoice.overdue | An open invoice passes its due date |
You only receive the event types you're subscribed to.
invoice.partial fires on every partial payment that leaves an outstanding balance,
so a single invoice can produce several invoice.partial events (each with its own
eventId); the payment that clears the balance fires invoice.paid, not invoice.partial.
invoice.paid and invoice.refunded can also fire more than once for one invoice. A
refund reopens the invoice, so an invoice that's paid, refunded and paid again sends two
invoice.paid events, and each refunded payment sends its own invoice.refunded. Each of
those events has its own eventId.
invoice.overdue fires once each time an invoice passes its due date. Moving an overdue
invoice's due date into the future returns it to Pending (or Partial, if it's been
partly paid) and sends no event; fetch the invoice if you need to see that change. If the
new due date then passes too, a second invoice.overdue fires with its own eventId.
invoice.created and invoice.voided fire at most once per invoice.
Payload
The body is a self-describing envelope. For every event type except the payment events (invoice.paid and invoice.partial, below), data.invoice is the public Invoice object returned by GET /api/invoices/{invoiceId}, as of when the event was dispatched (every retry of the same event resends the same body), apart from the fields listed under Not included in webhooks. No card data is ever included (payment details are limited to the card/bank last four). If you use custom fields, do not store cardholder data in them: their values are echoed back verbatim in this payload.
Payment events (invoice.paid, invoice.partial) are point-in-time, not current-state. data.invoice on these event types reflects the invoice as of the payment that triggered the event. invoiceStatus, amountPaid and paidAt come from that payment, not from whatever the invoice looks like by the time the event is delivered. On invoice.paid, transactions and amountDue are the exception: they're read when the event is first delivered, usually seconds after the payment, so a refund or void that lands in that window already shows in transactions. This matters most for invoice.partial, which a single invoice can fire several times (one per partial payment): a later payment never leaks into an earlier payment's event. updatedAt is the time of that payment. lastAttemptStatus, totalCharged and totalFees are included only when that payment's rail supplies them; otherwise they're omitted rather than showing a superseded value. paidAt never appears on invoice.partial, because the invoice isn't paid in full. transactions and amountDue are omitted from invoice.partial: they describe the invoice's live payment history and balance, which later payments may already have changed by the time this event is delivered. Fetch the invoice if you need them. The same fields as every other event type are left out (see Not included in webhooks).
{
"specVersion": 1,
"type": "invoice.paid",
"eventId": "evt_9f2c4a1b7e05d83a6c1f4b90e2d7a3c8",
"occurredAt": "2026-07-21T12:03:28.646Z",
"companyId": "…",
"data": {
"invoice": {
"invoiceId": "…",
"invoiceStatus": "Paid",
"customerId": "…",
"amount": 125.00,
"amountPaid": 125.00,
"dueDate": "2026-08-25T12:00:00.000Z",
"createdAt": "2026-08-23T14:07:11.482Z",
"paidAt": "2026-07-21T12:03:28.646Z",
"items": [
{
"itemId": "…",
"itemCode": "SKU-100",
"quantity": 1,
"unitPrice": 125.00,
"totalPrice": 125.00
}
]
}
}
}
| Field | Notes |
|---|---|
specVersion | Webhook contract version. Additive-only within a version; a breaking change bumps it. |
type | One of the event types above. |
eventId | Stable per invoice-status transition — use it as your idempotency/dedup key. A new transition of the same type on the same invoice (a second payment or refund) gets a new eventId. |
occurredAt | ISO-8601 timestamp of when the invoice changed status. Stable across retries — every delivery attempt for the same event carries the same occurredAt. |
data.invoice | The public Invoice object — the same shape as the REST API, minus the fields under Not included in webhooks, including ISO-8601 UTC date strings for every date field (dueDate, invoiceDate, createdAt, updatedAt, paidAt, and embedded transaction dates). See Dates & Timestamps. |
data.invoice.transactionId | Payment events only (invoice.paid, invoice.partial): the transaction that triggered this event, the same id as transactions[].id on GET /api/invoices/{invoiceId}. Use it to tell several invoice.partial events for one invoice apart. Present when the triggering payment identifies its transaction; otherwise absent. Not part of the REST Invoice object. |
data.invoice.transactions | The invoice's payment and refund history, in the same shape as the REST API. Card/bank details are limited to lastFour. Each transaction carries feeAmount, the convenience fee in dollars, which is 0 when no fee was charged. A payment also carries subtotalAmount, the amount applied to the invoice in dollars, when its fee breakdown was recorded. Empty when no payment has been attempted. Read when the event is first delivered, so on invoice.refunded the refund itself may not be listed yet: it's recorded when the payment processor confirms it, which can land just after the event. Fetch the invoice to see it. Not on invoice.partial. |
data.invoice.amountDue | What's still payable: amount minus settled payments minus any ACH payment still settling. Size a payment to this, not to amount − amountPaid. Not on invoice.partial. |
data.invoice.items[].itemCode | As-billed item code for a line — use it to map the line back to your catalog without a separate product lookup. For a catalog product this is that product's code (SKU). On a subscription invoice the base plan line carries the plan id here instead, because a plan has no SKU of its own; on those lines itemCode and itemId are the same value. Captured when the line is written and not refreshed afterward, so a later product rename/reprice never rewrites the line retroactively. Absent for ad-hoc line items, when the matched product has no code set, or on invoices created before this field shipped. |
Deliveries are at-least-once and unordered: dedup on eventId, and apply each invoice's events in occurredAt order, not arrival order (a refund can arrive after the payment that followed it), discarding any event older than the last one you applied for that invoice.
Keep the first delivery of an eventId and ignore later ones. Each invoice-status transition is dispatched once, and the scheduled retries under Retries resend that same body byte for byte, with the same eventId. An eventId repeats only when the same transition is delivered again: a retry, a replay you request, or, rarely, a redelivery after an infrastructure fault. It never repeats for a new transition: a second payment or refund on the same invoice arrives under a new eventId, so keeping the first delivery per eventId never drops one. A repeated eventId can still carry a different data.invoice in two cases: a replay (it reuses the original eventId and occurredAt, so it still sorts where the transition happened, but carries the invoice as of the replay; a replayed invoice.created for an invoice voided since shows invoiceStatus: "Voided") and an infrastructure-fault redelivery. Either way the duplicate describes the same transition. Any field that differs (most often transactions, amountDue or updatedAt) reflects later activity on the invoice, not a correction to the event. If you need the invoice's current state, fetch it with GET /api/invoices/{invoiceId}. Don't overwrite what you stored from the first delivery.
Not included in webhooks
These Invoice fields are returned by GET /api/invoices/{invoiceId} but are never part of data.invoice. Fetch the invoice when you need them. (invoice.partial events also omit transactions and amountDue; see above.)
| Field | Why |
|---|---|
customer | Customer personal data (name, email, phone, address). Webhooks are delivered to your endpoint rather than read through an authenticated API call, so they identify the customer by customerId only. |
pdfUrl | The PDF renders the same customer personal data, and the link is a presigned URL that expires after one hour, which is inside the webhook retry window. |
HTTP Headers
POST {your-url}
Content-Type: application/json
X-PayConnect-Signature: sha256=<hex>
X-PayConnect-Timestamp: 1753098208
X-PayConnect-Event-Id: evt_…
X-PayConnect-Event-Type: invoice.paid
If you configured endpoint authentication, PayConnect also sends the credential you provided (Authorization: Basic … / Authorization: Bearer …, or a custom API-key header).
X-PayConnect-Event-Id and X-PayConnect-Event-Type are convenience hints — the same values appear in the signed body (eventId, type). The signed body is authoritative: verify the signature, then read event data from the body, not the headers.
Verifying the Signature
X-PayConnect-Signature is sha256= + the HMAC-SHA256 of `${timestamp}.${rawBody}` using your signing secret.
- Read
X-PayConnect-Timestamp; reject if it's more than 5 minutes from now (replay protection). - Compute
HMAC_SHA256(signingSecret, timestamp + "." + rawRequestBody)over the raw body bytes, hex-encoded. - Compare against the
sha256=value using a constant-time comparison. Reject on mismatch. - Dedup on
eventId.
const crypto = require('crypto');
function verify(req, signingSecret) {
const sig = req.headers['x-payconnect-signature'];
const ts = req.headers['x-payconnect-timestamp'];
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // replay window
const expected = 'sha256=' + crypto
.createHmac('sha256', signingSecret)
.update(`${ts}.${req.rawBody}`)
.digest('hex');
const a = Buffer.from(sig), b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Retries
Delivery outcomes are classified:
- Retried — a
5xx,408,429, timeout, or connection error. PayConnect retries with exponential backoff (30s, 60s, 120s, …capped at 15 minutes) for about 8 attempts (~1 hour total) before the delivery is parked. - Not retried — any other
4xx(400,401,403,404,422, …). These are treated as permanent rejections and the delivery is dropped immediately. Fix your endpoint and the next event will flow, or ask support to replay the missed delivery.
Return 2xx as soon as you've durably accepted the event; do slow work asynchronously.