> ## Documentation Index
> Fetch the complete documentation index at: https://docs.beinfi.com/llms.txt
> Use this file to discover all available pages before exploring further.

# 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](https://beinfi.com/en/primeira-venda). This is the second one: **the buyer
paid, now they need to receive it.**

<Info>
  **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.)
</Info>

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.

<Tip>
  **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`.
</Tip>

## One-time purchases only

The deliverable exists only on a `type: "item"` product with
`pricingModel: "one_time"`. Any other combination:

```json theme={null}
// 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:

```ts theme={null}
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:

```ts theme={null}
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.

<Info>
  **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.
</Info>

## 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.

## Serving it yourself (recommended)

Do not depend on the buyer's inbox. You have the link:

```ts theme={null}
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.

<Warning>
  **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.
</Warning>

## 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.

<Warning>
  **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.
</Warning>

## Replacing and removing

```ts theme={null}
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

```ts theme={null}
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: "buyer@x.com", 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](https://beinfi.com/en/primeira-venda).

## 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](https://beinfi.com/en/reembolso).

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`.
