Get started
The thank-you page
The buyer's side: from the payment screen to them receiving it, on both selling paths.
The other pages cover what you do: catalog, charging, deliverable. This one covers what happens on the buyer's side — and it is where integrations get stuck, because it is the part with intermediate states.
The whole thread is this, and it holds on both selling paths:
payment screen → they pay → you find out they paid → you deliver
↑ polling ↑ polling again
Those two polling steps are the part nobody guesses. One at a time.
Path A: you have the invoice (checkout())
You have had invoiceId from the start, so this is the short path:
const { invoiceId } = await infi.checkout({
slug, productId,
customer: { externalId: "u_1", email, taxId },
idempotencyKey: `sale:${orderId}`,
});
const pay = await infi.pay.charge({ slug, invoiceId, method: "pix" });
// render pay.pixPayload as a QR (or use pay.pixQrImage)Store the invoiceId → your user pair before showing the screen. It is how
you will know whose purchase it was when the payment comes back.
Path B: you sent a payment link
The link is the recommendation for anyone who does not want to build a screen. If you want to control the experience and still use a link — or to test the flow over HTTP — it is three steps, and the invoice only exists at the third.
# 1. what to show on the screen (public, no auth)
curl "$API/pay/$SLUG/links/$TOKEN"
# -> { "merchant": {...}, "product": "Guide", "testMode": true, "cardEnabled": false }
# 2. open the session — email and taxId are REQUIRED
curl -X POST "$API/pay/$SLUG/links/$TOKEN/sessions" -H 'Content-Type: application/json' \
-d '{"email":"[email protected]","name":"Ana","taxId":"52998224725"}'
# -> 201 { "sessionId": "fa90ea6d-…", "product": {...}, "status": "…", "expiresAt": "…" }
# 3. charge — THIS step materialises the invoice
curl -X POST "$API/pay/$SLUG/links/$TOKEN/sessions/$SESSION_ID/charge" \
-H 'Content-Type: application/json' -d '{"method":"pix"}'
# -> 201 { "invoiceId": "2ae1b610-…", "pixPayload": "…", "sandboxConfirmUrl": "…", … }Three things that cost time if you do not know them:
- The invoice does not exist before step 3. The session has no
invoiceId— it appears in the charge response. Do not look for it earlier. - The charge is on the session, not on the invoice.
/sessions/{id}/charge, not/invoices/{id}/charge. The second one exists and serves path A. - No
email→400 "E-mail is required."; notaxId→400 "A valid CPF or CNPJ is required."Both in Portuguese, both before any charge happens.
These routes do not require an `Idempotency-Key`
The rule "every non-GET method requires the key" applies to the authenticated
API. The public /pay/* routes — the ones the buyer's browser calls — accept
requests without it.
The `taxId` `422` arrives at the charge, not at checkout
checkout() accepts a customer without a tax id and creates a finalised and
numbered invoice. The 422 customer_tax_id_required only shows up at
pay.charge, and that invoice can no longer be paid — it stays open forever in
your reporting.
The validation lives at the charge because the party requiring the document is
the method's provider, and the method is only chosen there. Collect taxId
before creating the invoice; if you already created one without it, clean up:
await infi.invoices.void(invoiceId); // -> status "void"The same holds for a customer with no name and no email: one of the two is
required (422 customer_name_required).
Finding out they paid
const paid = await infi.pay.waitForPaid({ slug, invoiceId, intervalMs: 700, timeoutMs: 15000 });Always pass timeoutMs. The default is 600000 — ten minutes — so on an
unpaid invoice, which is the normal case, your handler hangs instead of
answering.
In production the payment.confirmed webhook is the right path; in sandbox
registering one answers 503, so it is polling — details in
webhooks.
Do not read the invoice only once
Confirmation arrives through the provider's webhook, a moment after you trigger
the payment. A single read returns open and you show "waiting" to somebody who
already paid. Measured: the invoice turns paid in under 1s sometimes, and in
about 3s other times — the variance is the provider's network, not yours.
Delivering — and the second poll
Here is the mistake you can make with the whole doc open in front of you: the
grant does not exist the instant the invoice turns paid. Delivery runs after
the payment confirms, so the first read returns [].
if (paid) {
let grants = [];
for (let i = 0; i < 10 && grants.length === 0; i++) {
grants = await infi.invoices.deliverable(invoiceId);
if (grants.length === 0) await new Promise((r) => setTimeout(r, 500));
}
if (grants[0]) showButton(grants[0].downloadUrl);
}An empty list is a 200, never a 404 — deliberately, precisely so this is
safe to poll in a loop. The rest of the deliverable is in
delivering the product.
Testing all of this end to end
In sandbox you close the loop yourself, with no provider key:
if (pay.sandboxConfirmUrl) {
await fetch(pay.sandboxConfirmUrl, { method: "POST" });
}Check the field, never the shape of pixPayload — in production the field
does not come and the payload is EMV. That is what separates a test button from
a production bug. It is spelled out in
testing in the sandbox.
The whole page, together
// GET /thanks?invoice=...
const invoiceId = req.query.invoice;
const paid = await infi.pay.waitForPaid({ slug, invoiceId, intervalMs: 700, timeoutMs: 15000 });
if (!paid) return render("waiting", { invoiceId }); // let them reload
let grants = [];
for (let i = 0; i < 10 && grants.length === 0; i++) {
grants = await infi.invoices.deliverable(invoiceId);
if (grants.length === 0) await new Promise((r) => setTimeout(r, 500));
}
return render("thanks", {
download: grants[0]?.downloadUrl, // may be undefined: product with no deliverable
order: await myDb.byInvoice(invoiceId),
});download coming back undefined is not an error: it is a product with no
deliverable, or delivery that has not run yet. In both cases, showing "your
access arrives by email in a moment" beats an empty screen — and the email does
go out, as long as the address is real.
If the sale is reversed
Refunding is not only giving money back: with a digital product the buyer already has the file, and the link is still in their inbox. What decides what happens to the access is the amount of the refund — full switches it off, partial does not. It is in refunds.