Get started
Delivering the product
Attach the file or the link to the product and Infi delivers on its own when payment confirms.
Selling a digital product has two halves. The first — charging — is in your first sale. This is the second one: the buyer paid, now they need to receive it.
Requires `@beinfi/[email protected]`
invoices.deliverable(), the invoiceId that checkout() returns and
presign with narrowed types landed in 0.10.4. On an earlier version the HTTP
routes already exist — call them directly. (0.10.3 was published with a stale
bundle and has none of this; do not use it.)
You attach one deliverable to the product. When a payment confirms, Infi creates a personal link for that buyer, emails it, and leaves the same link available for you to serve from your own thank-you page.
It does not depend on a webhook
Delivery is internal: it runs when payment.confirmed leaves our queue, not
when your webhook answers. Which means it works in sandbox even with
webhook registration returning 503.
One-time purchases only
The deliverable exists only on a type: "item" product with
pricingModel: "one_time". Any other combination:
// PUT /metering/products/{id}/deliverable -> 422
{ "error_code": "deliverable_not_allowed",
"message": "Deliverables are only allowed on one_time item products." }A subscription has no deliverable — what a subscriber receives is access, and
that is subscription, not a download.
Attaching
Two ways. A product has one deliverable: saving again replaces the previous one.
A link (the shortest path)
Good for Notion, Drive, a Vimeo video, your own members' area:
await infi.products.deliverable.save(productId, {
kind: "link",
url: "https://yoursite.com/members/guide",
});A file
Three steps, because the bytes go straight from your process to storage without passing through our API:
import { readFile } from "node:fs/promises";
const bytes = await readFile("./coffee-guide.pdf");
// 1. ask for the signed URL
const { uploadUrl, objectKey } = await infi.products.deliverable.presign(productId, {
fileName: "coffee-guide.pdf",
contentType: "application/pdf",
sizeBytes: bytes.byteLength,
});
// 2. push the bytes to it (PUT, with none of our auth headers)
await fetch(uploadUrl, {
method: "PUT",
headers: { "Content-Type": "application/pdf" },
body: bytes,
});
// 3. register the object on the product
await infi.products.deliverable.save(productId, { kind: "file", objectKey });Step 3 checks that the object really exists before saving — invert the order and
it refuses with
objectKey: "uploaded object was not found; upload before saving". And it fills
in sizeBytes/contentType on its own when you omit them: uploading a PDF
without declaring anything returns contentType: "application/pdf" and the real
size.
The uploadUrl is good for 15 minutes. It only writes that one object, so
you can send it straight from your admin's browser without passing your sk_
along.
If the environment has no storage, `presign` answers 503
503 storage_unconfigured means that environment has no object storage
configured — not that you got the call wrong. Use kind: "link" in that case:
the rest of the flow (grant, email, download) is identical either way.
What happens when the payment confirms
In this order, with nothing from you:
- Infi resolves the buyer and the deliverable for that invoice.
- Creates a grant: a unique token for that payment.
- Sends the email, subject
Seu acesso / Your download is ready, with the link.
If the same event is reprocessed, it finds the grant again and does not send
a second email — the guarantee is in the database
(UNIQUE (payment_id, deliverable_id)), not in luck.
A product with no deliverable is not an error: delivery simply does nothing.
Serving it yourself (recommended)
Do not depend on the buyer's inbox. You have the link:
const grants = await infi.invoices.deliverable(invoiceId);
// [{ paymentId, token, downloadUrl, emailSentAt, createdAt }]
if (grants.length > 0) {
showButton(grants[0].downloadUrl); // your thank-you page
}While the invoice is unpaid, this returns 200 { "grants": [] } — an empty
list, never a 404. That is deliberate: "not delivered yet" is a real state
you keep querying, and a 404 would be indistinguishable from a wrong id. So it
is safe to put in a polling loop, next to pay.waitForPaid.
Three reasons to prefer this path over the email:
- The email may simply not go out, and
emailSentAtis how you know. It stays null when sending did not happen — and the case that catches most people testing is an address that does not really exist. Measured:@example.com(a reserved domain, no provider delivers it) →emailSentAtnull forever; a real address → filled in about 8s. The grant is born in both cases, so the sale is deliverable even when the email was not. - A buyer with no email at all also generates a grant. Without this call the sale is half-delivered and you do not know it.
- The email can land in spam. Your thank-you page cannot.
This route requires the secret key
invoices.deliverable runs with sk_, scope billing:read — never with a
publishable key. The token is a credential: whoever holds it downloads the
product, with no further proof of purchase. That is also why it does not appear
in the invoice's public response: an invoice id travels in URLs, browser history
and support tickets, and the download must not travel with it. Fetch it on your
server and hand it to the browser you just charged.
The download link
GET /pay/{slug}/download/{token}
Public, with no auth header — the token is the credential. It answers 302:
| deliverable | redirects to |
|---|---|
link | the URL you saved |
file | a fresh signed storage URL, valid for 5 minutes |
An unknown token answers 404. In the SDK, if you already hold the token,
infi.pay.downloadUrl(slug, token) assembles that URL.
The signed download URL is deliberately short (5 min) because it is generated on every click: whoever shares the signed link shares something that expires. What does not expire is the token — see below.
The link does not expire and has no usage limit
Today the grant has neither a validity nor a counter: whoever holds the token
downloads as many times as they like, forever. It is personal because it is
secret, not because it is verified. Treat it like a password — do not log it, do
not put it in a URL you share. If your product needs real access control, use
kind: "link" pointing at an area you authenticate yourself.
Replacing and removing
await infi.products.deliverable.get(productId); // what is attached today
await infi.products.deliverable.save(productId, {…}); // replaces it
await infi.products.deliverable.delete(productId); // removes it (idempotent)delete also erases the file from storage. Grants already issued stop
resolving — yesterday's buyer loses access, so replace with save rather than
deleting when the idea is to publish a new version.
The whole flow
import { Infi } from "@beinfi/sdk";
const infi = new Infi({ secretKey: process.env.INFI_SECRET_KEY! });
// once, when registering the product
await infi.products.deliverable.save(productId, {
kind: "link",
url: "https://yoursite.com/guide.pdf",
});
// on every sale
const { invoiceId } = await infi.checkout({
slug,
productId,
customer: { externalId: "u_1", email: "[email protected]", taxId: "52998224725" },
idempotencyKey: `sale:${orderId}`,
});
const paid = await infi.pay.waitForPaid({ slug, invoiceId, timeoutMs: 15000 });
if (paid) {
// Delivery runs AFTER the payment confirms, so the grant appears a moment
// after `paid`. A single read returns [] and you show a thank-you page with
// no download. Do the loop.
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 { download: grants[0]?.downloadUrl };
}taxId is not optional for pix, and idempotencyKey is what keeps a double
click from charging twice — both are explained in
your first sale.
And if you refund?
The grant is a capability with no deadline: whoever holds the token downloads. A
full refund switches it off — the link starts answering 410 and the grant
comes back with revokedAt — and a partial refund does not. The whole rule,
including how to override it, is in refunds.
That covers the file only. If your product grants access to something else (a
members' area, a Discord role, an API key), you are the one who cuts it:
subscribe to payment.refunded and read accessRevoked.