Limits
Read this before you build on it. Everything here is a real residual case, most of them have a one-click fix in your dashboard, and one of them can cost you goods.
The one that costs money
Section titled “The one that costs money”A false confirmation means you shipped for free. No system that infers which order a payment was for can make that impossible, and this one does not claim to. It is why every confirmation carries an evidence label — so you can decide what is strong enough for what value of goods, rather than treating all four the same.
There are also no chargebacks and no dispute process. There is no mechanism for one, because no intermediary is holding the money. What reached your account is in your account, and undoing it is between you and your customer.
Residual ambiguity
Section titled “Residual ambiguity”A late payer whose amount has since been reserved and tapped by somebody else produces a credit that could belong to either order. We park it rather than guess. The rest rule — a lapsed amount is passed over for an hour by default while any other free amount exists — makes this need a payment more than an hour late at a busy price point, but it does not make it impossible. Rest is tenant-configurable up to two hours and never shorter than an hour; see Reservation timing.
You get: a payment.ambiguous webhook and a row in Unplaced payments with
both candidate orders. One click.
Copy-the-UPI-ID rounding
Section titled “Copy-the-UPI-ID rounding”iOS has no system-wide UPI chooser, so some customers fall back to copying your UPI ID and typing the amount by hand. Some of them round it — ₹498.99 becomes ₹499. That amount matches no reservation.
This is the single largest source of parked credits.
You get: a payment.unplaced webhook with the right order as a candidate,
and delta_paise recorded when you place it. Show the “pay this exact amount”
warning next to the copy fallback; it is the cheapest prevention there is.
The same person, twice, quickly
Section titled “The same person, twice, quickly”If the same UPI handle pays the same amount within 30 minutes, the second credit is parked as a duplicate. That is right far more often than it is wrong — accidental double payments are common — but a genuine repeat purchase at the same price inside half an hour gets caught by it too.
You get: payment.duplicate with the candidate order listed. One click.
iOS gets no receipt
Section titled “iOS gets no receipt”Android apps can read the UTR back from the UPI app and confirm on the bank’s own reference number, independent of the amount. iOS grants an app nothing back. iOS payments confirm on the amount like everyone else’s. This is a platform limit and no future version fixes it.
Band exhaustion
Section titled “Band exhaustion”If every amount from your list price down to the bottom of your discount band
is reserved at once, reserve returns 503 pool_exhausted rather than handing
out an amount that cannot be told apart from another order.
Widening the band is not the fix — it buys capacity by giving away more money per order. If your volume is genuinely past what a 99-paise band holds, the fix is a merchant VPA from your own bank, whose channel returns your order reference directly and removes amount variation completely. Money still never touches us. That feed is not built yet.
Rate limits
Section titled “Rate limits”Every public endpoint is rate limited, always keyed on an identity you already
hold — a tenant, a page token, or a device — never on IP address, so one
office behind a NAT is never throttled as if it were an attacker. A 429
carries {"error":"rate_limited","retry_after_seconds":60} and a retry-after
header telling you how long to wait.
| what it limits | ceiling | keyed by |
|---|---|---|
the pay page (/p/{token}/*) |
60/60s | the page token |
the rest of the API (POST /v1/intents, .../reserve, and the rest of /v1/*) |
120/60s | your tenant |
requesting a sign-in link (POST /app/login) |
60/60s per address, plus 30/60s in aggregate | the address, then everyone |
pairing a device (POST /v1/devices/pair) |
10/60s per source, plus 60/60s in aggregate | the source, then everyone |
the reporter app (POST /v1/reports) |
60/60s | your tenant and device together |
replaying a webhook delivery (.../replay) |
10/60s | your tenant |
A reusable product link (GET/POST /pr/{slug}) reuses the pay page’s and the
API’s own counters, keyed on the link’s slug instead.
Signup and device pairing are the two actions an unauthenticated caller can take, so each carries a second, constant-keyed ceiling on top of its own per-address or per-source one — otherwise N addresses or N source IPs would each get their own budget, and our own domain would end up mailing, or a guesser probing, without limit. The aggregate ceiling caps that. Because Cloudflare’s counters are per data-centre, it is a strong bound, not a strict global guarantee.
Webhook delivery has a ceiling
Section titled “Webhook delivery has a ceiling”Order matching itself scales the way the reservation capacity table describes. Webhook delivery does not, and we would rather you knew the real number than a reassuring one.
On the Workers Free plan, one delivery attempt costs three subrequests — a
claim, the POST to your endpoint, and the write that records the result —
out of a 50-subrequest budget the five-minute sweep also spends on other
bookkeeping before it delivers anything at all. That works out to roughly
144 deliveries an hour, globally, across every tenant, every event type,
and every retry — not 144 per tenant.
At real volume this bites: 1,000 orders an hour with ordinary abandonment puts
intent.expired alone past 300 events an hour, and expiry events get no
inline first attempt, so all of it comes out of this same budget. Past roughly
480 orders an hour the queue grows faster than it drains.
Nothing is lost when this happens — an undelivered event waits its turn and stays visible and replayable in Settings — but it can wait. A Workers Paid plan raises the subrequest budget from 50 to 10,000 and this ceiling rises with it. This is a stated ceiling, not a solved problem.
Email latency is unmeasured
Section titled “Email latency is unmeasured”We have no figure for how long your bank takes to send a credit alert email, or how long Gmail takes to forward it. We will not quote one we have not measured.
What we do instead: a credit that reaches us more than 30 minutes after the
bank’s own timestamp is parked as stale rather than matched, and one with no
usable timestamp is parked too. We never fall back to the time we happened to
receive something. The reporter app removes the question
entirely for anyone who installs it — on a live credit, 160 ms from SMS to
server.
No order binding, which is the root of all of it
Section titled “No order binding, which is the root of all of it”Everything above is engineering around one fact: the order reference the payer
sets never reaches the payee. A payment gateway’s webhook carries your
order_id directly and this entire correlation layer disappears. It costs
about 2% in platform fees, plus 0.4% MDR above ₹2,000 since 15 October 2026.
That is the trade. We think it is a good one for a lot of sellers. It is not a good one for everybody and we would rather you knew which you were.
One bank
Section titled “One bank”HDFC only. The parser is built for more — keyed on the sending domain, one module and one test corpus per bank — but only HDFC has one today.
Where parked credits go
Section titled “Where parked credits go”Nowhere, until you look at them. Parked credits are never dropped and never
turned into an error. Your account receives money that has nothing to do with
orders, and the right behaviour is to ignore all of it while leaving a trail.
They are in GET /v1/credits and in Unplaced payments, with the reason and the
candidates, until you place one on an order or dismiss it as not an order.
Dismissing keeps the row for audit.
Every failure has a definite end
Section titled “Every failure has a definite end”| what happens | what we do |
|---|---|
| the same credit arrives twice, or on both feeds | nothing. The bank’s reference number is the primary key. |
| a customer pays twice | duplicate, with the candidate order. |
| a payment lands after its reservation lapsed | confirms that order, evidence late — or parks, if your policy says so. |
| a late payment where the amount was reused and both orders tapped | ambiguous, both listed. |
| a credit that is not for an order at all | unplaced. Ignored, kept, never actioned by us. |
| a forwarded mail delayed past 30 minutes, or with no date | stale. |
| a message we cannot parse | our operators are alerted. The body is never stored. |
| every amount in the band is held | 503 with a retry-after. |
| you send requests faster than a limit allows | 429 with retry-after; nothing you sent takes effect. |
| a forged email | rejected at the DKIM check. Nothing stored. |
| a revoked or unknown device token | 401. Nothing stored. |
| your webhook endpoint is down | six attempts, then it waits in Settings for you to replay it. |
| your feed goes silent | our operators are alerted when an account makes reservations and sees no credits at all. |
Nothing guesses.