Skip to main content
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/sdk@0.10.4. 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:
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:

A file

Three steps, because the bytes go straight from your process to storage without passing through our API:
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:
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.
Public, with no auth header — the token is the credential. It answers 302: 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

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

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.