Stripe PaymentIntents API Review: 22 of 24 on the Scorecard, and the 12 Findings Your Integration Must Own
Written by Ahmed at Analyst Engineering, a Senior Technical Business Analyst with 10+ years in banking and payments delivery.
Key takeaways
- Scored with the Analyst Engineering Review Scorecard, Stripe's PaymentIntents API reaches 22 of 24: every check is explicit except two, the lifecycle (unquantified automatic cancellation) and event duplication (unspecified cases).
- Stripe keeps an idempotency key for at least 24 hours and replays the first result, including 500 errors; a retry after the window creates a new PaymentIntent, and a retry within it can never turn a failure into a success.
- Stripe webhook events use the API version pinned on the webhook endpoint when it was created, not the version of the SDK making requests; upgrading one without the other changes the shape of events.
- Stripe does not guarantee webhook ordering and may deliver an event more than once; deduplicate on the event id, and on the object id plus event type, never on the created timestamp, which has one-second resolution.
- A high scorecard result does not mean nothing to do: 22 of 24 still produced 12 findings and 9 requirements the consuming team has to write, because an explicit rule on the provider's side is a decision on yours.
Scored with the Analyst Engineering Review Scorecard, Stripe’s PaymentIntents API gets 22 of 24. Ten of the twelve checks are explicit with numbers; the lifecycle loses a point for an automatic cancellation that is never quantified, and concurrency loses one for duplicate events whose cases are never named. The verdict is “integrate”. The register still holds 12 findings, three of them high, because a rule that is explicit on Stripe’s side is a decision on yours.
This is the first teardown in the scorecard series: the same 12 checks, applied in public to a real, well-documented contract, with the evidence quoted and the findings written the way I would hand them to a delivery team on a Monday. The point is not to grade Stripe. The point is to show what a contract review produces even when the contract is good, and to let you calibrate your own scoring against a worked example.
Scorecard teardown 01. Reviewed on 21 September 2026 against Stripe’s public documentation, current API version
2026-08-26.dahlia. Scope: the PaymentIntent object and its endpoints, idempotent requests, errors, pagination, expansion, rate limits, versioning, webhooks, and separate authorization and capture. Out of scope: Checkout Sessions, Connect, and SDK specifics. The rubric is on the scorecard page; the checklist and register template are free to download there.
Why review an API everyone already trusts?
Because the score and the findings answer two different questions. The score answers “how explicit is this contract?”, and Stripe’s answer is: more explicit than almost any internal API you will meet. The findings answer “what must my team decide before the first request?”, and that list does not shrink because the provider wrote things down. It grows, because now the rules are specific enough to break you in specific ways.
A team integrating Stripe rarely reads the idempotency page, the versioning page, the webhook delivery page, and the authorization hold page in one sitting. The scorecard forces that reading. What follows is what it turned up.
The result: 22 of 24
| Check | Score | Why |
|---|---|---|
| C1 Vocabulary | 2 | One term per concept; legacy fields (source, charges) remain beside latest_charge but are labelled |
| C2 Lifecycle | 1 | Seven statuses and their transitions are documented; “canceled if confirmed too many times” has no number, and cancellation from processing “might fail” within a window that is not stated |
| C3 Money and time | 2 | Integer minor units, lowercase ISO 4217 currency, amount up to eight digits, Unix seconds throughout |
| C4 Operations | 2 | Create, update, confirm, capture, cancel, increment, list, search; partial capture rules stated |
| C5 Errors | 2 | Four error types, stable code and decline_code, param, doc_url, request_log_url; which messages are user-safe is stated |
| C6 Limits | 2 | Rate limits per account, per endpoint, and per PaymentIntent; metadata, key length, descriptor length, hold windows all numeric |
| C7 Idempotency | 2 | Header, 255 characters, 24-hour retention, replay of failures, mismatch behaviour, which methods honour it |
| C8 Concurrency and ordering | 1 | Lock timeouts and out-of-order delivery are explicit; “in some cases, two separate Event objects are generated” names no cases |
| C9 Authorization and secrets | 2 | Key types per operation, client_secret handling, HMAC SHA-256 signatures with a 5-minute tolerance, secret rolling |
| C10 Versioning | 2 | Named versions, monthly backward-compatible releases, header override, endpoint-pinned event versions |
| C11 Traceability | 2 | Stable ids everywhere, request log URLs in errors, metadata with numeric limits, Search API |
| C12 Evidence | 2 | Sandbox, CLI event triggers, examples per endpoint, documented sandbox differences |
Twenty-two. Verdict: integrate, and send the question list this week. Here is the question list.
Lens 1, meaning: what does the contract say the business means?
F11: Which charge field is the current one?
Check C1. Severity: note. The PaymentIntent object carries latest_charge, documented as “null until PaymentIntent confirmation is attempted” and expandable, and also still exposes source, a field from the pre-PaymentIntents era. The docs label the current field clearly, so this is a note, not a finding against the contract. It becomes a finding on your side the moment a developer copies an older code sample.
Decision: standardize on latest_charge, expanded when the charge details are needed (expand[]=latest_charge), and reject source and charges in code review. Expansions nest up to four levels with dot notation, and on list endpoints they start with data..
F6: Is the payment lifecycle a line or a loop?
Check C2. Severity: medium. The seven statuses are documented, and so is the rule most state diagrams miss: “If the payment attempt fails (for example, due to a decline), the PaymentIntent’s status returns to requires_payment_method so that the payment can be retried.” That makes the lifecycle a cycle. The same page adds that PaymentIntents “might also automatically transition to canceled if they’re confirmed too many times”, and the number of times is not stated anywhere I could find. Cancellation from processing is allowed for bank debit methods but “might fail due to a limited and varying cancellation time window”.
This is the point the contract lost. It is also the finding that changes an internal design: an order model with one payment attempt per order will not survive a decline followed by a second card.
Decision: model payment attempts as a list under the order, not a field. Cap confirmations yourself (three is a common choice) and cancel with cancellation_reason=abandoned when the cap is hit, so your own cancellation reason appears in the object rather than Stripe’s automatic. Treat processing as a state from which cancellation is a request, not a guarantee.
C3, money and time: nothing to add, one thing to remember
Amounts are “a positive integer representing how much to charge in the smallest currency unit”, with a minimum of 0.50 USD or equivalent and a maximum of eight digits (99,999,999). Currency is a lowercase ISO 4217 code. Every timestamp is “measured in seconds since the Unix epoch”. That is a 2, and it is also why F4 below exists: one-second resolution is not an ordering key.
Lens 2, completeness: does it cover the whole process?
F9: What happens to the rest of a partially captured authorization?
Check C4. Severity: low. With capture_method=manual, a confirmed PaymentIntent lands in requires_capture, and POST /v1/payment_intents/{id}/capture takes an amount_to_capture. The rule that matters for fulfilment is explicit: “A partial capture automatically releases the remaining amount”, and for most payments “you can only perform one capture on an authorized payment”. Cancelling a requires_capture intent refunds the remaining amount_capturable.
Decision: if the business ships in parts, one authorization cannot pay for two shipments. Either capture once for the whole order, or save the payment method and create a second PaymentIntent for the second shipment. Write that down before the warehouse designs its process around “capture as we ship”.
F5: Where does the decline go when you touch the intent?
Check C5. Severity: medium. The error model is a 2: four types (api_error, card_error, idempotency_error, invalid_request_error), a code, a decline_code when the issuer gives one, param for field-level errors, and a request_log_url into the dashboard. The docs are explicit that only card error messages “can be shown to your users”. The trap is in the PaymentIntent object itself: last_payment_error is “the payment error encountered in the previous PaymentIntent confirmation. It will be cleared if the PaymentIntent is later updated for any reason.”
Any update. Change the amount after a decline, add metadata, or attach a new payment method, and the decline reason is gone from the object.
Decision: persist last_payment_error (at least code, decline_code, and the charge id) in your own records before any update call, and derive your support and analytics views from that copy, not from the live object.
C6, limits: the check Stripe passes that your own APIs usually fail
Everything is a number. Global rate limit 100 requests per second in live mode and 25 in a sandbox; 25 per second per endpoint; 1,000 update requests per PaymentIntent per hour; 20 per second on the Search API. Metadata: 50 keys, 40-character key names, 500-character values, no square brackets in keys. statement_descriptor: 22 characters, and setting it on a card charge returns an error (use statement_descriptor_suffix). Rate-limited responses carry a Stripe-Rate-Limited-Reason header with one of five values. Read requests have an allocation of 500 per transaction over a rolling 30 days, with a floor of 10,000 per month.
One number lives in an unexpected place, and that is F7.
F7: Where is the authorization expiry, and how long is it?
Check C6. Severity: medium. For online card payments, an authorization is “typically on hold for 7 days”, but the validity table shows Visa merchant-initiated transactions at 5 days (exactly 4 days and 18 hours) and in-person Mastercard, American Express, and Discover at 2 days. If it expires, “the funds are released and the payment status changes to canceled”. The precise expiry for a given payment is exposed on the Charge, as payment_method_details.card.capture_before, not on the PaymentIntent.
Decision: for any manual-capture flow, read capture_before from the expanded latest_charge right after confirmation, store it against the order, and schedule the capture with at least a day of margin. Alert on holds within 24 hours of expiry. Do not hard-code seven days.
Lens 3, safety: can you retry, race, and log without losing money?
F1: What happens if you retry a create after 24 hours?
Check C7. Severity: high. Stripe’s idempotency is a 2 because every rule is written: the Idempotency-Key header, “up to 255 characters”, version 4 UUIDs suggested, results saved “regardless of whether it succeeds or fails”, a parameter mismatch rejected, and “you can remove keys from the system automatically after they’re at least 24 hours old. We generate a new request if a key is reused after the original is pruned.”
That last sentence is the finding. A nightly batch that re-runs a failed job the following night, a support tool that “resends” a stuck order two days later, a queue with a long dead-letter delay: each of them can send the same key after pruning and get a second PaymentIntent for the same order.
Decision: the idempotency key is generated per payment attempt and stored with the order before the request is sent. Automatic retries with the same key are allowed only within a window your team fixes below 24 hours (I use 20). After that window, the only permitted action is a lookup by your own reference (F13) followed by a human or rule-based decision to create a new attempt.
F2: What does a replayed 500 mean for your retry policy?
Check C7. Severity: high. The same page: “Subsequent requests with the same key return the same result, including 500 errors.” And: “We save results only after the execution of an endpoint begins. If incoming parameters fail validation, or the request conflicts with another request that’s executing concurrently, we don’t save the idempotent result.”
Read together, these say that retrying a 500 with the same key can never succeed, because you will get the saved 500 back. Retrying with a new key can succeed, and can also duplicate a payment if the first request actually went through before the 500 was produced. A key reused while the first request is still executing is rejected with a 409 Conflict, and that rejection is not saved either.
Decision: on a 5xx or a timeout from a create or confirm, do not retry blindly with either key. First reconcile: search for a PaymentIntent carrying your order reference in metadata (Search API, 20 requests per second) or list recent intents and match. If none exists, create with a new key. If one exists, continue from its status. Write this as a requirement with its own test case, because it is the one nobody tests until finance asks.
F4: What do you deduplicate webhook events on?
Check C8. Severity: medium. This is the second point lost. What is explicit is good: “Stripe doesn’t guarantee the delivery of events in the order that they’re generated”; “Webhook endpoints might occasionally receive the same event more than once”; “Snapshot events record created in seconds, so distinct events can share a timestamp. Don’t use created to determine event order.” What is not explicit: “In some cases, two separate Event objects are generated and sent. To identify these duplicates, use the ID of the object in data.object along with the event.type.” Which cases is not said, so the consumer has to defend against all of them.
Delivery retries run “for up to three days with an exponential back off in live mode”, three attempts over a few hours in a sandbox, a 3xx response counts as a failure, and the handler must return a 2xx quickly and do the work asynchronously.
Decision: the webhook handler verifies the signature, stores the raw event, returns 200, and hands off to a queue. The queue worker deduplicates twice: on event.id, and on the pair (data.object.id, event.type) within a window you choose (24 hours is a reasonable default). Ordering comes from the object’s own status, retrieved from the API when in doubt, never from created.
On the API side, concurrency is a 2: concurrent mutations on one object can return 429 with code: lock_timeout, which the SDKs retry automatically and which you should serialize per object rather than parallelize.
F8: Which value in every response must never reach a log?
Check C9. Severity: medium. Authorization is explicit: a publishable key confirms with confirmation_method=automatic, a secret key is required for manual, and webhooks are verified with the Stripe-Signature header (t= timestamp and v1= HMAC SHA-256 signatures) with a default tolerance of 5 minutes, never 0. The client_secret field, present on every PaymentIntent response fetched with a secret key, “should not be stored, logged, or exposed to anyone other than the customer”.
Server-side logging of full API responses at info level is the norm on delivery teams. That norm leaks client_secret into log aggregation, ticketing screenshots, and support exports.
Decision: a redaction list in the logging configuration containing client_secret and the webhook signing secret, plus a test that fails if either string appears in the logs of the payment service’s integration test run. Roll the webhook secret on a schedule; Stripe supports a 24-hour overlap with two active secrets.
Lens 4, evolution: will it still be correct next year?
F3: Which API version do your webhook events use?
Check C10. Severity: high. Versioning is a model 2: named releases (the current version is 2026-08-26.dahlia), monthly releases that “include only backward-compatible changes”, major releases that do not, and a Stripe-Version header to override the account default per request. The finding is one sentence in the webhook documentation: “Webhook events use the API version that’s set during your webhook’s endpoint creation. Otherwise, they use your Stripe account’s default API version.”
Meanwhile, “starting from stripe-node v12, the requests you send using stripe-node align with the API version that was current when your version of stripe-node was released” (likewise stripe-python v6, stripe-ruby v9, stripe-php v11). So a routine dependency bump moves your request version forward, your event version stays where the endpoint was created, and the same object arrives in two shapes through two doors.
Decision: the webhook endpoint’s API version is a configuration item owned by the same change that upgrades the SDK, and the two are never upgraded separately. Add an integration test that parses a triggered event with the SDK’s types. The breaking changes article has the general pattern; this is its most concrete instance.
F13: Where does your order reference live, and who can see it?
Check C11. Severity: note. Traceability is a 2: ids on every object, a request log URL in every error, metadata for the consumer’s own references, and a Search API. Two adjacent fields do different things. metadata is never shown to the customer and is not used by Stripe for any decision. description, on the other hand, “might be seen by your users (for example, in email receipts Stripe sends on your behalf)”.
Decision: metadata.order_id (and any other internal reference) is mandatory on every create; description is reserved for text the customer may read. Neither ever contains card or bank data, which the docs prohibit.
F10: Can you load test against the sandbox?
Check C12. Severity: low. Evidence is a 2: a sandbox, stripe trigger payment_intent.succeeded and its siblings for every event type, examples on every endpoint. The sandbox’s differences from live are also documented, which is rare: the global limit is 25 requests per second instead of 100, and “creating a charge in live mode sends a request to a payment gateway and that request is mocked in a sandbox, resulting in significantly different latency profiles”. Stripe discourages load testing against it.
Decision: the test strategy load tests against a mock of the Stripe API that sleeps for latencies sampled from production, and uses the sandbox for functional and contract tests only.
The findings register
| Id | Check | Location | Finding | Severity | Decision |
|---|---|---|---|---|---|
| F1 | C7 | Idempotency-Key | Keys pruned after 24 hours; reuse then creates a new object | High | Retry with the same key for under 24 hours; reconcile before a new key |
| F2 | C7 | Idempotency-Key | Saved results are replayed, including 500s; validation and concurrent conflicts are not saved | High | On 5xx or timeout, search by order reference before any retry |
| F3 | C10 | Webhook endpoint version | Events use the endpoint’s pinned version, not the SDK’s | High | Upgrade endpoint version and SDK in one change, with a parse test |
| F4 | C8 | Event delivery | No ordering; duplicates; unspecified double-event cases; created in seconds | Medium | Dedupe on event.id and on (data.object.id, type); order by object status |
| F5 | C5 | last_payment_error | Cleared by any update to the intent | Medium | Persist the decline before any update call |
| F6 | C2 | status | Failed attempt returns to requires_payment_method; auto-cancel threshold unstated | Medium | Attempts as a list; own confirmation cap with abandoned |
| F7 | C6 | capture_before (on the Charge) | Hold windows vary by network, 2 to 7 days; expiry cancels the intent | Medium | Store capture_before per order; capture with a day of margin |
| F8 | C9 | client_secret | Present in every response; must not be logged | Medium | Redaction list plus a log-scanning test |
| F9 | C4 | amount_to_capture | Partial capture releases the rest; one capture for most payments | Low | One authorization per shipment, or save the method |
| F10 | C12 | Sandbox | 25 requests per second and mocked gateway latency | Low | Load test against a latency-sampled mock |
| F11 | C1 | latest_charge vs source | Legacy fields beside the current one | Note | Standardize on latest_charge, expanded |
| F13 | C11 | metadata vs description | Description may reach the customer; metadata never does | Note | metadata.order_id mandatory; description customer-safe |
F12 was merged into F4 during the write-up (the created resolution is part of the ordering finding). Ids are never reused, which is why the register skips one.
What would I hand to the developers on Monday?
Nine requirements, each traceable to a finding, each with an obvious test:
- Idempotency key per attempt. Generated and stored with the order before the create request. Same-key automatic retries stop 20 hours after first use. (F1)
- Reconcile before recreate. After a 5xx or a timeout on create or confirm, search PaymentIntents by
metadata.order_idbefore deciding to create with a new key. (F2) - Coupled versions. The webhook endpoint’s API version equals the SDK’s pinned version, changed in the same pull request, with a test that parses a triggered
payment_intent.succeededevent. (F3) - Webhook intake. Verify the signature with the 5-minute tolerance, store the raw event, return
200, queue. Deduplicate onevent.idand on (data.object.id,event.type) within 24 hours. Never order bycreated. (F4) - Decline capture. Persist
last_payment_error.code,decline_code, and the charge id before any update call on the intent. (F5) - Attempt model. Payment attempts are a list under the order. After three failed confirmations, cancel with
cancellation_reason=abandoned. (F6) - Hold tracking. For manual capture, store
capture_beforefrom the expandedlatest_charge; schedule capture at least 24 hours before it; alert on the last day. (F7) - Redaction.
client_secretand the webhook signing secret are on the log redaction list, and a test scans the integration test logs for both. (F8) - Reference fields.
metadata.order_idis mandatory;descriptionholds only customer-safe text; neither holds payment credentials. (F13)
That is the deliverable. Not the score: the score is how you got the team to read nine pages of documentation in an afternoon.
How do you run this review yourself?
Take the scorecard, the Markdown checklist, and the CSV register, and spend two hours with the provider’s reference, guides, changelog, and error list. Score each check 0, 1, or 2 against the “what a 2 looks like” line, not against your impression. Then write a finding for every 0 or 1, and for every 2 that still forces a decision on your side. If your total lands near mine for Stripe, your calibration is good enough to score the internal API your team is about to freeze.
To practise on a contract built for it, Mission 01 of the Labs gives you the Northline Pay OpenAPI contract, requirements, sample responses, and business rules, and a solution that uses the same severities as this register.
The takeaway
Stripe’s PaymentIntents API scores 22 of 24 because almost every rule a consumer needs is written down with a number. That is exactly why the review produced 12 findings: a 24-hour key retention, a replayed 500, an endpoint-pinned event version, and a cleared last_payment_error are all explicit, and each one is a requirement your integration has to own. Score the contract, write the register, hand over the requirements. The grade is for the provider; the register is for your team.
For writing contracts that score a 24 the first time, see API Documentation from Scratch, and for turning a register like this one into a test suite, API Testing and QA Mastery for BAs. Want a contract on your own project scored before the developers start? Book a 1:1 Tech BA Coaching Call.
Ahmed is a Senior Technical Business Analyst with 10+ years in banking and payments. He builds practical guides and tools for analysts at The Tech BA Toolkit. Stripe is a trademark of Stripe, Inc.; this review is independent and based only on public documentation.
About the author
Analyst Engineering is written by Ahmed, a Senior Technical Business Analyst with 10+ years of banking and payments delivery experience: ISO 20022 and SWIFT messaging, payments API integration, Kafka event validation, and production support. Every article comes from real delivery work, and each one is reviewed and updated as tools and standards change.
Related articles
- API Design Review: The Analyst's Checklist Before the Contract Is Frozen How analysts review an API design before build: domain naming, state changes, money and dates, error model, pagination, idempotency, and a worked review.
- Idempotency Testing: Proving Duplicate Requests Are Safe How to test idempotency in APIs and event consumers: idempotency keys, duplicate requests, redelivered events, and the race conditions that cause double processing.
- Webhooks Explained for Analysts: How to Specify, Test, and Debug Them How webhooks work and what analysts must specify: events, signatures, retries, duplicates, and ordering, plus testing with webhook.site and the Stripe CLI.
- API Versioning and Breaking Changes: How Analysts Assess Impact Before a Release What counts as a breaking API change, versioning strategies, Deprecation and Sunset headers, detecting breaks with oasdiff, and consumer impact assessment.
Go deeper on this
Not ready to buy? The free downloads are a no-cost place to start, and every article here stays free.
Free account
Practice on the Labs, keep your progress
A free account, no password: an email link signs you in. It saves your steps and self-assessments on the Labs, shows your missions on a dashboard, unlocks the solutions, and, if you tick the box, sends you new missions and articles when they ship.
Your email is used to sign you in. Nothing else, unless you ask. Privacy.