Skip to content

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.

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:

  1. Infi resolves the buyer and the deliverable for that invoice.
  2. Creates a grant: a unique token for that payment.
  3. 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.

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 emailSentAt is 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) → emailSentAt null 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.

GET /pay/{slug}/download/{token}

Public, with no auth header — the token is the credential. It answers 302:

deliverableredirects to
linkthe URL you saved
filea 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.