Congrès Médicis is a medical congress in Iași, Romania. In 2026 it sold its places online: four packs, seats counted per pack and per course of study, an abstract fee and a bus seat that could join an order. The payments go through Netopia, a Romanian payment provider. Its API works. Its documentation tells you how to start a payment and very little about what happens after, which is the part that loses money when it’s wrong.
This is what the congress’s checkout does, and why. The backend is Convex; the code is TypeScript, shortened here, but the logic is what runs.
1. Never touch a card number
The first version had its own card form, and with it 3-D Secure redirects to handle by hand. In January it went:
Moved to Netopia’s hosted payment page, since we don’t have PCI-DSS certification.
That commit removed 3,538 lines. Now the server asks Netopia for a payment, Netopia answers with the address of its own payment page, and the buyer pays there. No card number ever reaches the app, which takes the whole site out of the scope of card-data rules.
The flow has four moves:
- The checkout calls an action that creates the order and asks Netopia to start a payment, with a
notifyUrl(where Netopia will tell the server what happened) and aredirectUrl(where it sends the buyer back). - The buyer is sent to Netopia’s page and pays.
- Netopia calls
notifyUrlwith the result: the IPN, its instant payment notification. - Netopia sends the buyer back to
redirectUrl, where the page waits for the order’s status to change.
Step 4 has its first trap. Netopia doesn’t reliably carry your order’s id back as a query parameter. So the id goes into the path, where it can’t be lost:
// Netopia is inconsistent about propagating orderId as a query parameter.
// We always encode orderId in the callback path, so the landing page can
// resolve it from the path, the query, or both.
function buildPaymentReturnUrl(baseUrl: string, orderId: string): string {
const callbackUrl = new URL(baseUrl);
const path = callbackUrl.pathname.replace(/\/+$/, "");
callbackUrl.pathname = `${path}/${encodeURIComponent(orderId)}`;
return callbackUrl.toString();
}
The return page is never the source of truth. A buyer can close the tab before it loads, and a forged return address must not confirm anything. Only the IPN does.
2. The IPN has to prove where it came from
Anyone can POST to a public webhook. Netopia signs each notification with a JWT in a verification-token header, and the documentation is thin on what to check. This is what the server checks, in order, and rejects on the first failure:
| Check | What it means |
|---|---|
alg is RS512 |
Refuse any other algorithm, none included. |
| The signature verifies with Netopia’s public key | It came from Netopia. |
aud contains your POS signature |
It was meant for your shop, not another merchant’s. |
sub is the base64 SHA-512 of the raw body |
The body wasn’t changed on the way. |
iat isn’t far in the future |
The token isn’t nonsense. |
The body check is the one that’s easy to get wrong. The hash is over the exact bytes Netopia sent, so you have to read the request as text before parsing it. Parse first and re-serialise, and the hash won’t match. Compare in constant time:
function verifyVerificationToken(
body: string,
token: string,
publicKeyPem: string,
expectedAudience: string
) {
const [encodedHeader, encodedPayload, encodedSignature] = token.split(".");
const header = decodeBase64UrlJson<{ alg?: string }>(encodedHeader);
const payload = decodeBase64UrlJson<JwtPayload>(encodedPayload);
if (!(header && payload)) return invalid("invalid_token_payload");
if (header.alg !== "RS512") return invalid("invalid_algorithm");
// 1. Signed by Netopia.
const verifier = createVerify("RSA-SHA512");
verifier.update(`${encodedHeader}.${encodedPayload}`);
const signature = Buffer.from(base64UrlToBase64(encodedSignature), "base64");
if (
!verifier.verify(createPublicKey(normalizePem(publicKeyPem)), signature)
) {
return invalid("invalid_signature");
}
// 2. Meant for this shop.
if (!extractAudiences(payload.aud).includes(expectedAudience)) {
return invalid("audience_mismatch");
}
// 3. About this exact body.
const provided = decodeBase64Claim(payload.sub ?? "");
const expected = createHash("sha512").update(body, "utf8").digest();
if (
!provided ||
provided.length !== expected.length ||
!timingSafeEqual(expected, provided)
) {
return invalid("body_hash_mismatch");
}
return valid(warningsFor(payload)); // issuer and timing oddities are logged, not fatal
}
Two details cost me time.
The public key arrives mangled. Stored in an environment variable, a PEM key usually comes back with literal \n sequences, sometimes wrapped in quotes. createPublicKey refuses it with an unhelpful error. Normalise it first:
function normalizePem(value: string): string {
let pem = value.trim().replace(/^(['"])(.*)\1$/s, "$2"); // stray quotes
pem = pem.replace(/\\n/g, "\n").replace(/\r\n?/g, "\n"); // escaped newlines
const match = pem.match(/-----BEGIN ([^-]+)-----([\s\S]+)-----END \1-----/);
if (!match) return pem;
const [, type, body] = match;
return `-----BEGIN ${type}-----\n${body.replace(/\s+/g, "")}\n-----END ${type}-----`;
}
The issuer isn’t always spelled the same. The claim is meant to read NETOPIA Payments. Variants turned up, so the check tolerates anything containing “netopia” with a warning in the logs instead of refusing a real payment. The signature, audience and body hash are what actually protect you. The issuer is a label.
Verification fails closed: a notification that doesn’t pass is answered 401 and changes nothing. A flag can switch enforcement to “log only” during an incident, but it’s on by default. When a notification is accepted, the server answers Netopia with a small JSON body that says so:
return new Response(JSON.stringify({ errorCode: 0 }), {
headers: { "Content-Type": "application/json" },
status: 200,
});
Netopia’s calls also come from a short list of addresses, with a recognisable user agent. The server logs when a notification comes from somewhere else, but doesn’t refuse it on that alone: the list changed once during the sale. The signature is the real gate.
3. One code, two meanings
Netopia reports a payment’s state as a number. These are the ones the congress met:
| Code | Name | Order becomes |
|---|---|---|
| 1 | New | pending |
| 3 | Paid | confirmed |
| 4 | Cancelled | cancelled |
| 5 | Confirmed | confirmed |
| 8 | Refunded | refunded |
| 11 | Error | failed |
| 12 | Invalid account | failed |
| 13 | Suspected fraud | failed |
| 15 | Redirect to payment, or requires authorisation | pending |
| 23 | Expired | cancelled |
Code 15 means two things: a 3-D Secure redirect and a payment waiting for authorisation. Here both mean “still pending”, so it’s harmless. But it means a lookup table written by hand can quietly map one code to two different outcomes, and whichever line comes last wins. So the table is built at startup and refuses to load if that ever happens, or if a status is left out:
for (const { name, orderStatus } of PAYMENT_STATUS_ORDER_MAPPINGS) {
const code = PAYMENT_STATUS[name];
const existing = map.get(code);
if (existing && existing !== orderStatus) {
throw new Error(
`PAYMENT_STATUS code collision for ${code}: ${existing} vs ${orderStatus}.`
);
}
map.set(code, orderStatus);
}
Anything unknown maps to pending, never to confirmed. An unexpected code should leave a seat held, not give it away.
4. The fewer seats, the shorter the wait
An unpaid order holds a seat. When seats are plentiful that costs nothing; when five are left, a buyer who wandered off is blocking someone who would pay. So how long an order may wait depends on how many seats remain when it’s placed:
| Seats left | Reminder after | Order lapses after |
|---|---|---|
| 5 or fewer | 3 min | 10 min |
| 6 to 15 | 5 min | 20 min |
| 16 to 30 | 10 min | 45 min |
| more | 15 min | 60 min |
Payment links that the organisers send by hand get days instead of minutes. An order can be rescheduled once, if capacity has improved since it was placed.
The trap is at the deadline. A buyer can pay in the last seconds, and the IPN can arrive after your timer fires. So before cancelling, the server asks Netopia directly, one last time:
// Last status check: the buyer may have paid since the timeout was scheduled.
const precheck = await syncOrderWithNetopiaStatusImpl(ctx, {
orderId: order._id,
});
if ([REFUNDED, CANCELLED, EXPIRED].includes(precheck.netopiaStatus)) {
await finalizeTimeoutCancellation(ctx, { orderId: order._id });
return { skipped: true };
}
// Still unpaid: expire it on Netopia's side too, so a late attempt can't succeed.
Only then does it cancel the order, and it tells Netopia to expire the payment as well. Otherwise the buyer could still complete it on the hosted page and pay for a seat that no longer exists.
5. Refunds come in pieces
One order can hold up to three separate Netopia payments: the pack, an abstract added later, and a bus seat bought on its own. A refund has to credit each of them through Netopia’s operations API, which may succeed for one and fail for another.
So a refund records which components it covers, credits them one at a time, keeps track of what has gone through, and only when the money is back does it undo the rest: it cancels the activity bookings and the bus seat, deletes the meal registrations, and revokes the certificates. The function reports each count back to the dashboard, so the organiser sees what changed.
6. Keep sandbox and production apart
Netopia’s sandbox and production use different keys and different addresses. A deployment that mixes them takes real orders and records sandbox payments, or the reverse. The project’s rules say it in one line:
ENV=productionandNETOPIA_SANDBOX_MODE=trueare incompatible.
And outside the sandbox, the configuration refuses to start unless the addresses Netopia calls back are HTTPS.
If you’re integrating Netopia
- Use the hosted payment page, and keep card numbers out of your app entirely.
- Put your order id in the return path, not only the query.
- Treat the IPN as the only source of truth, and read its body as raw text before anything else.
- Check the signature, the audience and the body hash, and fail closed.
- Normalise the PEM key you get from your environment.
- Answer
{ "errorCode": 0 }once you’ve accepted a notification. - Map unknown status codes to “pending”, and make collisions impossible.
- Ask Netopia once more before cancelling an unpaid order, then expire it there too.
- Keep sandbox and production keys in separate deployments.
None of this is clever. It’s the part the documentation skips.