Guide · Backend
Payment Gateway API Design: From Intents to Webhooks
26 min readUpdated By Saket Bhatnagar
Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.
In short
- Payment gateway API design is choosing the records, calls and events a gateway (the service between a shop and the banks) offers, so money moves only against a fixed amount. An API is the set of requests other programs can send a service.
- The seller's server creates a payment intent, one record of one thing to pay, using a secret key that never leaves the server. The browser only confirms it, so nobody can change the amount.
- Every call that moves money carries an idempotency key, a unique id for that one action. A retry after a timeout gets the first answer back instead of charging twice.
- Money moves in steps: authorize (the bank approves and reserves the amount), then capture (the money is taken). Signed webhooks, messages from the gateway's server to the seller's, report each result.
- Beginners can stop after the payment states section. Engineers get tested code for the parts docs skip: a key stuck after a crash, a race between two captures, and signature checks during secret rotation.
What goes wrong when a payment API is one charge call?
A one-call charge API fails because it squeezes three jobs into one request: fixing the price, asking the bank, and reporting the result. When the network drops halfway, nobody knows which jobs finished.
Picture a common setup. A payment gateway, the service between a shop's website and the banks, serves about 4,000 small online sellers in India, from saree shops to spice brands. The setup and its numbers are made up, but the problems are real.
Its first API (the requests sellers' code could send it) had one endpoint, an address those requests go to. The seller's checkout sent card or UPI details, the amount and the order number to it. UPI is India's instant bank-to-bank payment system. The gateway called the bank, waited, and returned the answer in the same response.
During one festive week, the gateway's integrations team kept seeing the same three problems in its support queue.
- Double charges. A seller's server gave up after 30 seconds and sent the charge again. The bank had approved the first one, so 212 shoppers paid twice.
- Changed amounts. One seller let the browser send the amount. A shopper edited it in the browser's developer tools and paid Rs 9 for a Rs 900 kurta.
- Lost orders. Sellers marked an order paid when the shopper reached the thank-you page. Shoppers whose phone died right after paying by UPI got no order at all.
All three share one root cause. The API treats a payment as a single moment. In real life a payment is a short journey with several steps, and any step can be slow or fail.
The rest of this guide builds version 2 of the gateway's API, with one fix for each problem.
What is payment gateway API design?
Payment gateway API design is deciding which records, calls, states and events a gateway offers, so sellers can take money safely. Good design makes the safe path the easiest path for the seller's developer.
Large gateways such as Stripe and Razorpay share a broad shape. The seller's server first creates a record of what should be paid. The shopper then pays against that record. Finally, the gateway tells the seller what happened.
A merchant is the seller who receives the money, and the shopper is the customer who pays. You need five more words for the rest of this guide:
- Payment intent: one record of one thing to be paid, with a fixed amount, currency and seller. Stripe calls it a PaymentIntent, and Razorpay calls its version an order.
- Authorization: the shopper's bank approves the payment and reserves the money. Nothing has reached the seller yet.
- Capture: the step that actually takes the reserved money, so it can be settled (paid out) to the seller.
- Idempotency key: a unique id the caller sends with a request, so a repeat of that same request is never done twice.
- Webhook: an HTTP request (a message in the web's standard request format) the gateway sends to the seller's server when something happens, such as a payment being captured.
The team writes three goals for version 2. Money must never move twice for one intent. The amount must be fixed on a server the seller controls. And every seller must learn the final result, even if the shopper closes the tab.
Here are the parts of version 2. Notice that the shopper's browser never talks to a bank, and the seller's server never sees a card number.
Version 2: the main parts
Drag to exploreNotice that the seller's server and the shopper's browser talk only to the gateway API. Results reach the seller as signed webhooks.
Diagram connections:
- Seller's server to Gateway API (Create intent)
- Shopper's browser or app to Gateway API (Confirm with card or UPI)
- Gateway API to Payments database (Save every state change)
- Gateway API to Banks and UPI network (Authorize and capture)
- Gateway API to Event queue (Publish event)
- Event queue to Webhook sender
- Webhook sender to Seller's server (Signed webhook)
- Shopper's browser or app: Enters card or UPI details in checkout
- Seller's server: Creates intents with its secret key
- Gateway API: Checks keys, amounts and repeat requests
- Payments database: Intents, attempts, refunds, keys and events
- Banks and UPI network: Approve, hold and move the money
- Event queue: Holds events waiting to be sent
- Webhook sender: Signs each event and retries
How does a payment intent stop shoppers from changing the amount?
A payment intent stops tampering because the seller's server creates it, amount included, before the shopper sees the pay button. The browser gets only an id and a short-lived secret, never a way to set the price.
Think of booking a car at a showroom. The sales manager writes the model, colour and on-road price on a booking form and gives it a booking number. Everything you pay later is paid against that number. You cannot walk up to the cash counter and announce a new price.
The intent works the same way. The seller's server calls the API with its secret key, the amount in paise and its own order number. Paise are the rupee's smallest unit, 100 to a rupee, so Rs 900 is sent as 90000.
The API replies with an intent id and a client secret. The client secret is a token that works only for this one intent. The seller's page hands it to the gateway's checkout script, which collects the card or UPI details and confirms the intent.
Real gateways explain the design the same way. Razorpay's Orders page says the order id secures the payment request, so the order amount cannot be tampered with. Stripe advises creating the intent as soon as you know the amount, so every attempted payment gets recorded.
One intent can have several attempts. If a card is declined, the shopper can try UPI against the same intent. Razorpay binds a single successful payment to each order, which blocks paying twice for the same order.
What are the states of a payment, from created to captured?
A payment moves through a fixed set of states: created, then authorized, then captured, with canceled and refunded as other endings. A failed attempt sends the shopper back to try again. Each state allows only certain next steps, and the API rejects everything else.
This list of states and allowed moves is called a state machine. Writing it down blocks impossible events, like refunding money that was never captured.
Back at the showroom, you pay a booking amount first and the balance on delivery, with a receipt at each step. Authorization and capture split a payment the same way: one reserves, the other completes.
Where the picture breaks: what authorization reserves depends on the payment method. For an online card payment, the bank holds the amount on the card and releases it if nobody captures it.
Razorpay describes the authorized state differently. Its docs say every authorized payment not captured within 3 days of creation is auto-refunded. So an uncaptured payment there goes back to the shopper as a refund.
The gap still helps some sellers. A bakery taking custom cake orders for the weekend can authorize a card on Thursday and capture on Saturday. If the oven breaks, they cancel, the card hold is released, and no refund is needed.
Holds do not last forever. As of 19 October 2026, Stripe's docs say an online card authorization is usually valid for 7 days. Razorpay's limit is the 3 days above, so capture well inside your gateway's window.
Most sellers simply turn on auto-capture, which captures right after authorization. Follow the arrows below from created.
The states of one payment
Drag to exploreEach arrow is a legal way into the next state. Notice the loop from a failed attempt back to the shopper, the late-answer arrow from failed to authorized, and that a partial refund leaves the payment captured.
Diagram connections:
- Created to Waiting for the shopper (Shopper confirms)
- Waiting for the shopper to Bank approves?
- Bank approves? to Authorized (Yes)
- Bank approves? to Failed attempt (No)
- Failed attempt to Waiting for the shopper (Try another method)
- Authorized to Captured (Capture)
- Authorized to Canceled (Cancel or window ends)
- Captured to Fully refunded (Refunds reach the total)
- Failed attempt to Authorized (Late bank yes (Razorpay))
- Created: Seller fixed amount and order number
- Waiting for the shopper: One-time password page or UPI app approval
- Bank approves?: A slow or missing answer stays pending
- Authorized: Card: held. Razorpay: refunded if not captured
- Captured: Money taken, settles to the seller
- Failed attempt: Retry on this intent; a late bank yes can follow
- Canceled: Hold released or money auto-refunded
- Fully refunded: Refund records add up to the captured amount
Some payments sit in the middle for a while. A UPI payment waits until the shopper approves it in their UPI app. Stripe has a processing state for slow methods such as bank debits, which can take a few days.
Sometimes the gateway never hears back at all. Razorpay calls this late authorisation: a network or technical fault leaves it unclear whether money left the shopper's account.
As of 19 October 2026, Razorpay's docs say it marks such a payment failed after 10 minutes without an answer. It keeps asking the bank for 3 days from creation and marks the payment authorized if the bank reports success. So a failed status is not always final: keep listening for the payment.authorized webhook before you close the order for good.
How the API allows only one move at a time
The API stores the state in one column and changes it only with a guarded update, an update that names the state it expects. Here is the capture in SQL, the language used to query and change a database. Your database driver fills in the :id and :amount placeholders.
UPDATE payment_intents
SET state = 'captured', captured_amount = :amount
WHERE id = :id AND state = 'authorized';
-- 1 row changed: this call won. 0 rows: another call got there first.If two capture calls race, only one finds the row still authorized. We raced two sessions on PostgreSQL 16.15 at its default isolation level (the setting for how sessions running at once see each other's changes). The second waited until the first committed, then printed UPDATE 0 and changed nothing.
A 0 means another call got there first. The API then reads the row and returns its current state instead of calling the bank again.
The single UPDATE shows the guard, but a real capture also calls the bank. So use two guarded updates: authorized to capturing before the bank call, then capturing to captured. If the bank declines, move the row back to authorized.
A bank timeout is an unknown outcome, not a failure: the money may have moved. Leave the row in capturing, and let the recovery job described below ask the bank about those rows too.
How does a payment gateway stop double charges when requests are retried?
A payment gateway stops double charges by requiring an idempotency key on every call that moves money. If the same key arrives twice, the gateway returns the saved first answer instead of doing the work again.
Go back to the 212 double charges. The seller's server sent a charge, the bank approved it, and the reply got lost on the way back. The seller could not tell a lost reply from a failed charge, so it retried. Retrying was right. Doing the work twice was the bug.
Stripe's documentation shows a mature version, as of 19 October 2026. In its v1 API, once work on a request starts, Stripe saves the status code and body, even for a 500 error (a crash on the server side).
A v1 retry with different parameters gets an error. Keys can be removed once they are at least 24 hours old, and a reused key after that starts a new request.
Stripe's newer v2 API behaves differently. A retry does not repeat a successful operation, and Stripe tries to finish failed or partly failed work, when possible, instead of replaying the error. Read your own gateway's rules before you build retries.
The team asks sellers to send a key in an Idempotency-Key header (a named field sent with the request) on every create, capture, cancel and refund. Its integration guide suggests a random UUID (a long random id) per action, saved with the seller's order before the first try.
The idempotency check in code
Here is the core of the team's check. The helpers are stand-ins. The helper db.insert_key adds a row and returns False if that seller already used that key, and run_capture does the real work with the bank.
import hashlib
import json
def handle_capture(seller_id, key, body):
fingerprint = hashlib.sha256(
json.dumps(body, sort_keys=True).encode()).hexdigest()
if not db.insert_key(seller_id, key, fingerprint): # key seen before
saved = db.get_key(seller_id, key)
if saved.fingerprint != fingerprint:
return 422, {"error": "key reused for a different request"}
if saved.response is None:
return 409, {"error": "first request still running, retry soon"}
return saved.status, saved.response # replay first answer
try:
status, response = run_capture(body)
except Exception:
status, response = 500, {"error": "internal error, read the payment status"}
db.save_response(seller_id, key, status, response)
return status, responseWhat just happened? We ran this handler on Python 3.13 with a SQLite stand-in for db. A same-key, same-body retry replayed the first 200 answer, with no second bank call. The changed amount got 422.
We also sent five copies at once while a slow stand-in bank call was still running. They got one 200 and four 409s, with exactly one bank call. Here is why. The insert_key call relies on a unique index, a database rule that no two rows share a seller and key.
The fingerprint, a hash of the request body (a short fixed-length value computed from the data), catches a key reused for a different amount. The 409 tells the caller the first copy is still running, so it should wait and retry.
A crash inside run_capture is saved as a 500 and replayed, like Stripe's v1 behaviour. In our test, the retry got the same 500 without a second bank call. The caller should then read the payment's status instead of retrying blindly.
Where an idempotency key can still get stuck
One gap remains. If the whole process dies between the bank's yes and save_response, the key row has no answer, and every retry gets 409. Our test confirmed it.
So a recovery job must find answerless rows and intents stuck in capturing after a few minutes, ask the bank for the true status, and fill in the answer. Without it, that key stays stuck.
Save the answer in the same database transaction (a group of writes that succeed or fail together) as the state change. Then the database never shows captured without a saved answer. The bank call itself sits outside that transaction, which is why the recovery job exists.
How do webhooks tell a merchant that a payment succeeded?
Webhooks tell the merchant by sending an HTTP POST to the merchant's server for each event, such as a captured payment or a processed refund. The gateway signs every event and retries delivery until the merchant replies with success, up to a time limit.
Webhooks fix the lost-orders problem. The shopper's phone may die right after approving a UPI payment, but the gateway's servers keep running. The event reaches the seller whether or not the shopper ever comes back.
This is the receipt at each showroom step. You get one receipt for the booking amount and another for the balance. Each has its own number, so the accounts team never counts one twice. Each webhook event carries its own event id for the same reason.
One checkout, step by step
Drag to exploreRead the second line of each box: it says who talks to whom. The amount is fixed in step one, and the order ships only after a verified webhook.
Diagram connections:
- Create the intent to Pass the client secret
- Pass the client secret to Confirm with UPI or card
- Confirm with UPI or card to Authorize and capture
- Authorize and capture to Publish payment.captured (Bank says yes)
- Publish payment.captured to Deliver signed webhook
- Deliver signed webhook to Verify, then mark paid (Signature valid)
- Create the intent: Seller server → Gateway API
- Pass the client secret: Seller server → Shopper's browser
- Confirm with UPI or card: Shopper's browser → Gateway API
- Authorize and capture: Gateway API → Bank
- Publish payment.captured: Gateway API → Event queue
- Deliver signed webhook: Webhook sender → Seller server
- Verify, then mark paid: Seller server → Seller's order table
A webhook endpoint is open to the whole internet, so anyone could post a fake payment message to it. That is why every event is signed. The gateway computes an HMAC, a fingerprint made from the message plus a secret only the gateway and the seller know.
Stripe signs its webhooks with an HMAC using SHA-256, a standard hash function. Razorpay uses HMAC-SHA256 to sign the checkout result. Stripe also puts a timestamp inside the signed text.
Stripe's docs say its libraries default to a 5-minute tolerance. In the source of stripe-python 16.0.0, that means rejecting an event signed more than 300 seconds ago. This stops replay attacks: resending an old, valid message.
Checking a webhook signature in code
Here is how a seller's server checks a webhook in the same format. The header carries t, a Unix timestamp (seconds since 1 January 1970), and one or more v1 signatures. The raw_body value must be the exact bytes received, before any JSON parsing (JSON is the text format the event is written in).
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def sign(raw_body, secret, timestamp):
signed = str(timestamp).encode() + b"." + raw_body
return hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
def verify_webhook(raw_body, header, secret, now=None):
now = time.time() if now is None else now
timestamp, received = None, []
for item in header.split(","):
name, _, value = item.strip().partition("=")
if name == "t":
timestamp = value
elif name == "v1": # two v1 values while a secret is rotated
received.append(value.encode())
if not (timestamp and timestamp.isascii() and timestamp.isdigit()) or not received:
return False # malformed header: reject, never crash
if abs(now - int(timestamp)) > TOLERANCE_SECONDS:
return False # too old or from the future
expected = sign(raw_body, secret, timestamp).encode()
return any(hmac.compare_digest(expected, r) for r in received)Why can there be two v1 values? While a seller rotates (replaces) a signing secret, the old and new secrets both stay valid for a while. Stripe allows up to 24 hours and sends one signature per active secret, so any match is enough.
Our check is a little stricter than the stripe-python source: it also rejects timestamps more than 5 minutes in the future. A bad header returns False instead of raising an error.
That matters because a simpler parser crashed in our tests. A header like t=abc made int() raise an error, and hmac.compare_digest refuses non-ASCII text, so the code now compares bytes.
The code also ignores every scheme except v1, as Stripe advises, so an attacker cannot push you onto a weaker one. Check it yourself: this block signs a sample event with a clearly fake secret, then tries five deliveries.
import json
secret = "whsec_test_fake_secret_000"
body = b'{"id":"evt_test_001","type":"payment.captured","amount":90000}'
t = 1760000000
header = f"t={t},v1={sign(body, secret, t)}"
print(verify_webhook(body, header, secret, now=t + 10)) # genuine
print(verify_webhook(body.replace(b"90000", b"900"), header, secret, now=t + 10))
print(verify_webhook(json.dumps(json.loads(body)).encode(), header, secret, now=t + 10))
print(verify_webhook(body, header, secret, now=t + 301)) # 301 s late
old = sign(body, "whsec_test_old_secret_999", t)
print(verify_webhook(body, f"t={t},v1={old},v1={sign(body, secret, t)}", secret, now=t + 10))What just happened? Run after the code above on Python 3.13, it prints True, False, False, False, True. The genuine event passes, and the edited amount fails.
Re-encoding the same JSON fails too, because json.dumps adds spaces after colons and commas. That is why the body must be raw. A delivery 301 seconds late fails, and a header carrying an old and a new signature passes.
Two more details matter. The timestamp is part of the signed text, so an attacker cannot change it without breaking the signature. The function compare_digest does not stop early at the first different character, so nobody can guess the signature piece by piece from timing.
Delivery rules after the signature
A valid signature is only the start. Delivery has its own rules:
- Retries. If the seller's server is down, the gateway retries with growing gaps. As of 19 October 2026, Stripe retries live-mode events for up to three days with exponential backoff (gaps that keep growing).
- Duplicates. The same event can arrive twice. Save each event id you process, and skip any id you have already seen.
- Order. Events can arrive out of order, so a refund event may beat its capture event. Fetch the current state from the API instead of trusting arrival order.
- Speed. Reply with a 200 quickly, then do slow work like emails from a queue. A slow reply counts as a failure and brings more retries.
A payment API is not finished until the result reliably reaches the seller. A separate guide on webhook design covers retry schedules in more depth.
How should a payment API handle refunds, partial captures and failures?
A payment API should treat each refund as a new record linked to the payment, never as an edit to the payment. The captured amount stays true forever, and every refund keeps its own amount, reason and state.
This keeps the money trail honest. A shopper buys three bangles for Rs 1,500, returns one, then returns another a week later. The seller refunds 50000 paise each time. The payment shows two refunds, and the API blocks any refund that would pass the captured total.
Refunds take an idempotency key too, for the same lost-reply reason. A seller who clicks refund twice in a slow admin panel should send back Rs 500 once, not twice.
Partial capture runs the other way. A grocery seller authorizes Rs 2,000 on a card for a basket, finds one item out of stock, and captures Rs 1,750. The gateway releases the remaining Rs 250 of the hold.
Stripe behaves the same way: a partial capture automatically releases the rest. For most payments it also allows only one capture per authorization, so you cannot capture the difference later.
Failures need stable error codes, not just messages. The gateway returns a type and a code, such as card_declined or upi_timeout, so seller code can branch on them.
Every gateway also needs reconciliation: a daily job that matches its own records against the settlement files banks send. Any payment where the two disagree goes to a person to check.
Here is the whole version 2 API in one place. Look at the last column: everything that moves money needs a key.
| Call | What it does | Who calls it | Idempotency key? |
|---|---|---|---|
| POST /v2/payment_intents | Creates an intent with amount, currency and order number | Seller's server, secret key | Yes |
| POST /v2/payment_intents/{id}/confirm | Attaches card or UPI details and asks the bank | Checkout script, client secret | Yes, sent by the script |
| POST /v2/payment_intents/{id}/capture | Takes held money, in full or in part | Seller's server | Yes |
| POST /v2/payment_intents/{id}/cancel | Releases a hold before capture | Seller's server | Yes |
| POST /v2/refunds | Creates a refund against a captured payment | Seller's server | Yes |
| GET /v2/payment_intents/{id} | Reads the current state | Seller's server | No, reading is safe to repeat |
| Webhook events | payment.authorized, payment.captured, refund.processed and more | The gateway, to the seller | Seller skips repeated event ids |
Check it yourself with ten refunds of Rs 0.10 each:
balance = 0.0
for _ in range(10):
balance += 0.1 # ten refunds of Rs 0.10, kept as floats
print(balance)
print(balance == 1.0)
paise = 0
for _ in range(10):
paise += 10 # the same ten refunds, kept as whole paise
print(paise)It prints 0.9999999999999999, then False, then 100. The float total is not one rupee, while the paise total is exact. We used a loop on purpose.
Python 3.12 and later give sum() a more careful algorithm, so sum([0.1] * 10) prints 1.0 there, while Python 3.11 prints the error. Real balances change one payment at a time, which is what the loop does.
How do you keep card data and API keys safe in a payment gateway?
You keep card data safe by letting it touch as few systems as possible, and API keys safe by giving each key the least power it needs. The gateway's design decides both.
Card data falls under PCI DSS, a standard from the PCI Security Standards Council, at version 4.x as of October 2026. It sets baseline technical and operational rules for anyone who stores, processes or transmits card data.
The more systems that see card numbers, the more there is to secure. That is why the checkout script collects card details itself and sends them straight to the gateway. The seller's server only ever sees an intent id and a status.
The team adds four more safeguards to version 2:
- Keys stay on servers and carry the least power they need. As of 19 October 2026, Stripe's docs recommend restricted keys, which hold only chosen permissions, over all-powerful secret keys.
- Keys can be rotated without downtime. When you rotate a key from Stripe's dashboard, the old and new keys both work for up to 7 days.
- Test and live keys are separate. Test keys reach a sandbox, a practice copy of the system where no real money moves.
- Every key is rate limited (capped at a set number of requests per second), so a stolen key or a buggy loop cannot flood the banks with requests.
Each webhook endpoint also gets its own signing secret, as Stripe does, so one leaked secret exposes only that endpoint.
The checkout result needs a check on the server too. Razorpay has merchants recompute an HMAC-SHA256 of the order id and payment id, joined by a pipe character, with their key secret.
Razorpay also says to use the order id saved on your own server, not the one checkout returns. A mismatch means the order should be rejected.
How do you explain payment gateway API design in a system design interview?
Walk through the life of one payment, and add a safety rule at every step where money could move twice or go missing.
Start with the records
Name the payment intent, the attempt, the refund and the event. Say that the seller's server creates the intent and fixes the amount.
Draw the states
Created, authorized, captured, plus failed, canceled and refunded. Say which moves are legal, and that every update checks the current state.
Make money calls idempotent
Every create, capture and refund takes a key. Describe the unique index, the request fingerprint, the replayed answer and the recovery job for stuck keys.
Report results with webhooks
Signed, timestamped, retried, deduplicated by event id, and never trusted for order. The seller ships only after verifying.
Handle slow and odd cases
Pending UPI payments, late answers, holds that expire, partial captures, and a daily reconciliation against the banks' settlement files.
Finish with security and scale
Card data stays inside the gateway and keys carry limited power. Then estimate load: 60,000 payments a day averages about 0.7 a second.
Even a peak ten times the daily average is only about seven payments a second for this gateway. The hard part of a payment API is correctness, not raw speed.
Questions people ask
- What is the difference between authorization and capture?
- Authorization means the shopper's bank approves the payment and reserves the money. Capture means the money is actually taken, so it can be settled to the seller. Splitting them lets a seller cancel before shipping. For cards that means no refund is needed. Authorizations expire, so capture within the window your gateway allows.
- Why do payment APIs send amounts in paise or cents?
- They use the smallest currency unit so every amount is a whole number. Most decimal fractions cannot be stored exactly as floating-point numbers, and small rounding errors add up across many payments and refunds. Razorpay, for example, takes order amounts in the currency subunit, so Rs 900 is sent as 90000.
- Should a merchant trust the redirect back to the website after payment?
- No. The redirect only says the shopper's browser came back, and it can be missing or faked. Mark an order paid only after a verified webhook, a signature check on your server, or a status call to the gateway. Show the shopper a pending message until one of those arrives.
- How long should a payment gateway keep idempotency keys?
- Keep them longer than the longest time a client might retry. In Stripe's v1 API, for example, keys may be removed once they are at least 24 hours old. Whatever window you choose, write it in your API documentation, so sellers know when a reused key starts a fresh request.
- Do read requests need an idempotency key?
- No. A read, such as fetching a payment's status, changes nothing, so repeating it is already safe. Stripe's v1 API, for example, accepts keys on every POST request. It says sending one with GET or DELETE has no effect, because those requests are idempotent by definition.
- What happens if a webhook never arrives?
- The gateway retries for a while, but the seller should not rely on webhooks alone. Razorpay suggests an immediate fetch call to check the status when a webhook has not arrived in time. Run a small job that reads any order still pending after some minutes. A daily reconciliation report catches anything both of those miss.
- Is a payment intent the same as a Razorpay order?
- They solve the same problem. Both are records the merchant's server creates first, with a fixed amount, before the shopper pays. Stripe calls its version a PaymentIntent and Razorpay calls its version an order. Field names and states differ, so read each gateway's documentation before you integrate.
Sources and further reading
- Stripe Docs: How Payment Intents and Setup Intents work
- Stripe Docs: Place a hold on a payment method
- Stripe API Reference: Idempotent requests
- Stripe Docs: Receive Stripe events in your webhook endpoint
- Stripe Docs: API keys
- Razorpay Docs: Payments overview (late authorisation summary)
- Razorpay Docs: Capture settings (3-day capture window, auto-refund)
- Razorpay Docs: Late authorisation
- Razorpay Docs: Orders
- Razorpay Docs: Standard web integration steps
- PCI Security Standards Council: PCI DSS
- Python docs: hmac.compare_digest
- Python docs: What's New In Python 3.12 (sum uses Neumaier summation)