Three signals, one of them trustworthy
With a hosted payment page your API never touches the card. You create an order at Telr, send the customer to the page it returns, and wait to hear how it went. You hear it three ways, and they are not equal.
- The return URL. The browser comes back to your authorised, declined or cancelled page. Useful for the user, worthless as proof: anyone can type that URL, and a closed tab never arrives at all.
- The callback. Telr posts a signed, form-encoded advice to your server (ivp_update_url). Good evidence once you check the signature, but it can arrive late, twice, or before the browser does.
- order/check. Your server asks Telr about the order by its reference. This is the answer you act on, and the one to fall back to whenever the other two disagree or go quiet.
Create the session once
A double click on Pay, a retry from a flaky phone connection, two tabs: all of them ask for a second payment session for the same order. Two live sessions means a customer can pay twice. Lock the order row, reuse the session if one is already stored, and save the new reference and URL in the same transaction that created them.
Retry the database work as much as you like. Never retry the call to the gateway. If it fails after the request went out, you do not know whether Telr opened a session, so the order goes on hold and the customer is told not to start another payment. An explicit rejection from Telr is different: delete the order and release the seats. And give the Telr call a short timeout, since the order row stays locked while you wait.
One catch: a hosted-page reference is used up by its first page load. When the same customer comes back later to pay for the same cart, ask Telr about the old session first, and replace it only if it is still pending.
await using var tx = await db.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct);
// UPDLOCK: two requests cannot both reach Telr before one has saved its session.
var order = await db.Orders
.FromSqlInterpolated($"SELECT * FROM Orders WITH (UPDLOCK, HOLDLOCK) WHERE Id = {orderId}")
.AsTracking()
.SingleAsync(ct);
if (order.PaymentUrl is not null && order.PaymentOrderRef is not null)
return new PaymentSession(order.PaymentUrl, order.PaymentOrderRef);
try
{
var session = await telr.CreateSessionAsync(order, ct); // method: "create"
order.PaymentUrl = session.Url;
order.PaymentOrderRef = session.Ref;
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
return session;
}
catch (Exception ex) when (ex is not GatewayRejectedException)
{
// The request may have reached Telr: hold the order, never open a second session.
throw new PaymentSessionUncertainException(ex);
} One hosted-payment session per order (EF Core, SQL Server) Amounts are text, and culture bites
Telr takes the amount as a string. ToString("F2") looks safe until the server runs with a culture that writes a comma, and "11,50" is not the price you meant. Format with the invariant culture, and keep a unit test that switches the thread culture to fr-FR to prove it.
Round where the money is decided, not at the end. Tax rounded per ticket, away from zero, with the order total as the sum of the rounded lines, means the number you send is the number in your database. Our first version calculated tax in double; it is decimal everywhere now.
foreach (var item in order.Items)
item.TaxAmount = Math.Round(item.Price * taxRate, 2, MidpointRounding.AwayFromZero);
order.TotalAmount = order.Items.Sum(i => i.Price + i.TaxAmount);
var amount = order.TotalAmount.ToString("F2", CultureInfo.InvariantCulture); // "11.50", never "11,50" The amount Telr sees, and the total it must match Trust order/check, not the redirect
The return page does not decide anything. It reads the order id from its URL and asks your API to verify, a few times, a couple of seconds apart. The API checks that the order belongs to the signed-in user, calls order/check with the stored reference, and only then compares and applies.
Compare everything you can: the reference, the cart id, the amount, the currency and the test flag. A test-mode answer reaching production, or a payment in the wrong currency, is a configuration bug that should fail loudly rather than mark an order as paid.
| order/check | Meaning | Order status |
|---|---|---|
| 3, transaction A, sale or capture | Paid | Authorised: fulfil |
| 2 | Authorised, not captured | On hold |
| 1 | Pending | Pending: ask again later |
| -1 or -2 | Expired or cancelled | Cancelled |
| -3 | Declined | Declined |
| anything else | Unknown | Error, and an alert |
The callback: check it, then always answer 200
Telr posts the advice as a form, so bind it as a form. It signs your secret key and fourteen of its fields with SHA1, joined by colons, the key first. Check the store id, the test flag and the currency against your settings before you even hash, and compare the hash in constant time.
Then answer 200, whatever happened. A bad signature, an unknown order or an amount that does not match gets logged, with the amount mismatch as critical, but an error response only tells a stranger what you check and invites retries that will never succeed.
if (f.tran_store != settings.StoreId || f.tran_test != settings.Test || f.tran_currency != settings.Currency)
return false;
var signed = string.Join(":", settings.SecretKey, f.tran_store, f.tran_type, f.tran_class, f.tran_test,
f.tran_ref, f.tran_prevref, f.tran_firstref, f.tran_currency, f.tran_amount, f.tran_cartid,
f.tran_desc, f.tran_status, f.tran_authcode, f.tran_authmessage);
var expected = Convert.ToHexStringLower(SHA1.HashData(Encoding.UTF8.GetBytes(signed))); // SHA1 is Telr's choice
return CryptographicOperations.FixedTimeEquals(
Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(f.tran_check ?? "")); Checking the transaction advice Apply the result exactly once
The callback and the browser’s verify call often arrive within the same second, and both want to mark the order paid, count the seats as sold and clear the cart. Read-then-write lets both through. A conditional update lets exactly one through: it only matches while the order is still pending or on hold.
- Sold counts move with an atomic SQL increment, not read, add, save. Two callbacks used to overwrite each other.
- If the reserved seats are gone by the time the payment lands, the order goes on hold for review instead of being fulfilled with someone else’s seats.
- Only the items that were checked out leave the cart. The customer may be browsing another event while the payment runs.
- Fulfilment lives in one service. We once had three handlers doing it, each with a different bug.
var claimed = await db.Orders
.Where(o => o.Id == orderId &&
(o.PaymentStatus == PaymentStatus.Pending || o.PaymentStatus == PaymentStatus.OnHold))
.ExecuteUpdateAsync(s => s
.SetProperty(o => o.PaymentStatus, status)
.SetProperty(o => o.PaymentTranRef, tranRef), ct);
if (claimed == 0)
{
// Someone else already settled it. A "paid" for an order we cancelled goes to a person.
if (status == PaymentStatus.Authorized && current is PaymentStatus.Cancelled or PaymentStatus.Declined)
logger.LogCritical("Late payment for released order {OrderId}: manual review", orderId);
return;
} Claim the order, then fulfil Reconcile what timed out
Seats are reserved for 30 minutes. When a reservation expires, the tempting move is to cancel the order and free the seats. But a local timeout is not proof that the gateway can no longer charge: the customer may be on the payment page right now.
A background job runs every five minutes. Expired orders that have a Telr reference get an order/check first, and whatever Telr says is applied. Orders that never reached Telr are cancelled. If the check fails, the reservation stays until the next run. Orders on hold expire too, or they lock seats forever.
Mobile: the app never reads the URL
The web checkout redirects the whole page (framed 0). The Flutter app opens the same hosted page in a WebView and watches for navigation back to its own return host, then closes the WebView and asks the API to verify. It never decides success from the URL it was sent to, for the same reason the web page does not.
Keys in the environment, and a deploy that refuses test mode
The store id, auth key and secret key live in environment variables (Telr__StoreId and friends), never in appsettings files that end up in a repository. The production deploy script also refuses to ship if the environment still says test mode or points the return URLs anywhere unexpected.
grep -Fxq 'Telr__Test=0' .env || { echo 'Telr is still in test mode'; exit 1; } One line in the deploy script Before you take real money
- Is the amount formatted with the invariant culture, and does it equal the sum of the rounded lines?
- Can a double click or a retry ever open a second session for the same order?
- Does the return page ask your server, and does your server ask Telr?
- Does the callback check store, test flag, currency, signature and amount, and still answer 200?
- Can the callback and the verify call both fulfil the same order?
- What happens to a payment that lands after the reservation expired?
- Are the keys out of the repository, and can production run in test mode by accident?
More notes
-
Sending leads to Odoo without losing one: a transactional outbox in ASP.NET Core
-
Arabic and English in one Angular app: direction, fonts and numbers
-
Incremental RAG indexing without losing the old index
-
Permission-heavy multi-tenant systems: make the access check boring
-
Staying hands-on while leading delivery