Technical · January 30, 2026 · By João Pereira, Founder, Build Up Labs · Updated August 28, 2026 · 8 min

Stripe Webhooks: A Practical Guide for Invoicing

How to process Stripe webhooks for Portuguese invoicing without duplicate documents: signatures, idempotency, event ordering, and Stripe Connect.

For Portuguese invoicing, a Stripe webhook is safe only when it verifies the signature against the raw body, records the event before responding, processes issuance asynchronously, and applies idempotency at both event and payment level. These layers prevent duplicate FT invoices or FR invoice-receipts when notifications arrive more than once, late, or out of order.

Why do webhooks require two levels of idempotency?

A webhook is an HTTP notification sent by Stripe when the state of a payment or Stripe invoice changes. Delivery is at least once: if the endpoint does not respond successfully, Stripe can deliver the same notification again. In live mode, new attempts can continue for up to three days, with increasing intervals.

Storing only the event ID, such as evt_123, prevents two deliveries of that event from being processed. It does not prevent two different events from representing the same payment. A renewal can produce payment_intent.succeeded and invoice.payment_succeeded, each with its own ID. If both start issuance, two valid and sequential fiscal documents are created and later require correction.

LayerStable keyFailure it prevents
DeliveryOrganisation + event IDRepeated delivery of the same webhook
IssuanceOrganisation + invoice or PaymentIntentDifferent success events for the same charge
DocumentOrganisation + provider + document IDRetry after the provider succeeds

Example: two deliveries of evt_123 are stopped by the first layer; evt_123 and evt_456 for the same invoice are joined by the second. This issuance key applies only to success events that can create the document. Refunds, disputes, and failed attempts retain separate keys because a payment can have several legitimate actions.

How is the signature verified without changing the body?

The signature depends on the exact bytes sent by Stripe. The endpoint must read the request once as text or bytes, obtain the Stripe-Signature header, and call the official library with that raw body and the whsec_... secret. JSON must only be interpreted after verification.

Middleware that converts the body to an object and serialises it again can change spaces, escapes, or property order. The content still looks like valid JSON, but it no longer matches the signature. A request with no signature, an invalid signature, or an empty body should receive a 400 response and must not enter the invoicing queue.

Example: instead of accessing an already parsed request.body, the handler reads request.text(), verifies the signature, and only then processes event.data.object.

When should the endpoint return 2xx?

The response should be quick, but not premature. Before returning 2xx, the integration verifies the signature, identifies the organisation, claims the idempotency key atomically, and stores the minimum data needed to continue. It then sends durable work to a queue and responds to Stripe. The invoicing provider call remains outside the HTTP request.

  1. Read the raw body and verify the signature.
  2. Resolve the organisation or Connect account from the verified event.
  3. Store the idempotency key and transaction durably.
  4. Place issuance in a persistent queue.
  5. Return 2xx and let the worker complete issuance.

Example: if the invoicing API is unavailable for 40 seconds, the endpoint does not wait. Stripe receives 2xx after the work has been stored, and the worker retries under a controlled policy. If neither the database nor the queue accepts the work, the endpoint should return an error so that delivery can be attempted again.

How is the PaymentIntent and invoice race avoided?

For payments without an associated invoice, payment_intent.succeeded can start the flow. For subscriptions and other payments invoiced by Stripe, invoice.payment_succeeded provides the invoice that should be the reference. Delivery order is not guaranteed, so it is not enough to expect the invoice event to arrive first.

The handler must detect whether the PaymentIntent belongs to an invoice before claiming the payment key. If it does, that event is acknowledged without issuing a document and the invoice is allowed to be processed. This order matters: claiming the invoice-based key first and only then ignoring the PaymentIntent would block the correct event when it arrived.

The association must respect the configured API version and the structures actually received. When the required object is incomplete, retrieving it from the Stripe API is preferable to guessing fields. Example: if a renewal PaymentIntent arrives at 10:00:00 and its invoice event at 10:00:02, the first is deferred and only the second creates the invoicing transaction.

What is the difference between invoice.paid and invoice.payment_succeeded?

The two events can carry identical invoice data, but they are not synonyms. invoice.payment_succeeded means that a payment attempt for the invoice succeeded. invoice.paid means that the invoice status changed to paid and can also occur when an invoice is marked paid out of band or settled from the customer's credit balance.

EventMeaning for invoicingRequired decision
payment_intent.succeededPaymentIntent completedProcess only when it is not linked to an invoice
invoice.payment_succeededInvoice payment attempt completedUse an invoice-based key
invoice.paidInvoice became paid through any supported routeDeliberately include or exclude out-of-band payments
charge.refundedFull or partial refund recordedUpdate the transaction and assess the fiscal correction

Example: an invoice manually marked as paid can produce invoice.paid without representing a new Stripe charge. An integration that treats the event as an alias can issue a document for a payment outside its expected flow.

How are repeated or out-of-order events processed?

Processing must not depend on a fixed sequence. Each event needs to inspect current state, retrieve missing objects, and decide whether to process, wait, ignore, or send the case to review. The event record should distinguish processing, processed, and failed, so an event that failed transiently can be reclaimed without opening two concurrent executions.

Example: customer.updated can arrive after a payment event that was created later. Issuance should not replace the payment's historical data with a current snapshot without checking the correct source. Likewise, manually resending a completed event should end as a no-op and return success.

What if the provider creates the document before the worker records success?

This is the most dangerous failure in a fiscal integration. The provider can create an FT invoice or FR invoice-receipt and the connection can drop before the application stores the document ID. The local transaction or job acknowledgement can also fail immediately after the provider responds. Repeating the call blindly can create a second legal document.

The defence combines a stable payment key, provider idempotency where it is available, a unique constraint on the document ID, and a recovery path. Before issuing again, the worker looks for a document already associated with the transaction and, when the API permits, queries the provider by external reference. If the document exists but the local relationship is missing, the invoice and transaction link are repaired without new issuance.

Example: the provider returns doc_789, but local persistence fails. On the next attempt, finding doc_789 should lead to persistence of the local invoice, not another creation request. This is acknowledgement of the asynchronous job and does not change the 2xx already returned to Stripe.

How is a Stripe Connect account identified correctly?

In an incoming Connect event, the connected account ID is in the top-level event.account property. The organisation should only be resolved from that value after the signature is verified. The Stripe-Account header has a different purpose: it is used in requests sent to the Stripe API on behalf of a connected account, not to identify an incoming webhook.

Example: an event with account: "acct_123" is associated with the organisation that owns that connection. When the invoice is later retrieved from the API, the outgoing request uses Stripe-Account: acct_123. Trusting an incoming header with that name would allow incorrect routing.

Should a refund or cancellation automatically issue an NC?

No. charge.refunded confirms a financial change in Stripe, but does not decide the fiscal correction on its own. FR means invoice-receipt; NC means credit note. A subscription cancellation also does not prove that a refund occurred or that there is an earlier fiscal document to correct.

The system must first locate the original invoice, distinguish a full refund from a partial one, check whether the document was finalised, and apply the appropriate accounting policy. When data or configuration is insufficient, the safe outcome is manual review. The guide to credit notes for Stripe payments explains this decision in detail.

Example: cancelling a subscription at the end of its term returns no money and does not justify an NC by itself. A partial refund of an issued FT invoice requires the amount and reason to be assessed before the correction is created.

How is FT or FR selected?

The event type should not select the fiscal document. FT means invoice; FR means invoice-receipt. For a completed Stripe payment, the payment is treated as settled. The choice between FT and FR depends on the organisation's configuration. Separately, draft or final issuance depends on the provider's capabilities and the organisation's choice. Fiscal and tax data is passed through from Stripe and the configured mapping, not derived from customer location.

Example: two organisations receive the same event type. One is configured for a final FT and the other for a supported FR draft. The webhook starts both flows without encoding the document type in the event name. For the complete context, see how to automate Stripe invoicing in Portugal.

Which tests should run before production?

The happy path is not enough. The Stripe CLI can forward events to a local environment and trigger examples, but the secret shown by stripe listen belongs to that session and does not replace the production endpoint secret.

  1. Send a valid body and confirm rejection after changing one byte.
  2. Deliver the same event ID twice in parallel.
  3. Reverse the order of the PaymentIntent and invoice events.
  4. Simulate provider success followed by a persistence failure.
  5. Test two Connect accounts and prevent cross-organisation access.
  6. Confirm that refunds and cancellations do not create an NC automatically.

Example: after the same payment is repeated ten times, there should be one local invoice, one transaction link, and one provider document ID. The result must also meet the requirements for a Portuguese invoice rather than relying on a Stripe receipt. Also see when a Stripe invoice is valid in Portugal.

Frequently asked questions

Why is storing the Stripe event ID not enough?

Because Stripe can send different events for the same payment and repeat each delivery. Store every event ID and, only for success events that trigger issuance, a stable Stripe invoice or PaymentIntent key. Refunds and failed attempts retain separate keys.

Can the signature be verified after parsing the JSON?

No. Verification requires the original bytes and Stripe-Signature header before any transformation. Parsing and serialising the JSON again can change whitespace, escapes, or property order and invalidate a valid signature. Interpret the content only after verification, and reject the request if that verification fails.

Is invoice.paid the same as invoice.payment_succeeded?

No. invoice.payment_succeeded means an invoice payment attempt succeeded. invoice.paid means the invoice became paid and can also occur when it was marked paid out of band or settled through the customer's credit balance. The handler must deliberately choose which event represents billable settlement.

Should a Stripe refund automatically create an NC?

No. A charge.refunded event confirms a financial refund, not the issuance of a Portuguese fiscal document. Locate the original invoice, assess the refund amount and reason, and only then apply the configured accounting policy or send the case to manual review.

Sources

Automate Stripe invoicing with Faturado

Connect Stripe, TOConline, or InvoiceXpress and validate the flow with usage-based pricing.