Começar
Entregar o produto
Anexe o arquivo ou o link ao produto e a Infi entrega sozinha quando o pagamento confirma.
Vender um produto digital tem duas metades. A primeira — cobrar — está em primeira venda. Esta é a segunda: o comprador pagou, agora ele precisa receber.
Requer `@beinfi/[email protected]`
invoices.deliverable(), o invoiceId que o checkout() devolve e o presign
com tipos estreitados entraram na 0.10.4. Em versão anterior, as rotas HTTP já
existem — chame direto. (A 0.10.3 foi publicada com bundle velho e não tem
nada disso; não use.)
Você anexa um entregável ao produto. Quando um pagamento confirma, a Infi cria um link pessoal pra aquele comprador, manda por e-mail, e deixa o mesmo link disponível pra você servir da sua própria página de obrigado.
Não depende de webhook
A entrega é interna: roda quando o payment.confirmed sai da nossa fila, não
quando o seu webhook responde. Ou seja, funciona em sandbox mesmo com o
registro de webhook devolvendo 503.
Só em produto de compra única
O entregável existe apenas em produto type: "item" com
pricingModel: "one_time". Qualquer outra combinação:
// PUT /metering/products/{id}/deliverable -> 422
{ "error_code": "deliverable_not_allowed",
"message": "Deliverables are only allowed on one_time item products." }Assinatura não tem entregável — o que o assinante recebe é acesso, e isso é
subscription, não download.
Anexar
Duas formas. Um produto tem um entregável: salvar de novo substitui o anterior.
Um link (o caminho mais curto)
Serve pra Notion, Drive, um vídeo no Vimeo, sua própria área de membros:
await infi.products.deliverable.save(productId, {
kind: "link",
url: "https://seusite.com/area-de-membros/guia",
});Um arquivo
Três passos, porque os bytes vão direto do seu processo pro storage sem passar pela nossa API:
import { readFile } from "node:fs/promises";
const bytes = await readFile("./guia-do-cafe.pdf");
// 1. peça a URL assinada
const { uploadUrl, objectKey } = await infi.products.deliverable.presign(productId, {
fileName: "guia-do-cafe.pdf",
contentType: "application/pdf",
sizeBytes: bytes.byteLength,
});
// 2. suba os bytes pra ela (PUT, sem header de auth nosso)
await fetch(uploadUrl, {
method: "PUT",
headers: { "Content-Type": "application/pdf" },
body: bytes,
});
// 3. registre o objeto no produto
await infi.products.deliverable.save(productId, { kind: "file", objectKey });O passo 3 confere que o objeto existe de verdade antes de salvar — se você
inverter a ordem, ele recusa com
objectKey: "uploaded object was not found; upload before saving". E ele
preenche sizeBytes/contentType sozinho se você omitir: subir um PDF sem
declarar nada devolve contentType: "application/pdf" e o tamanho real.
A uploadUrl vale 15 minutos. Ela é só pra escrever aquele objeto, então dá
pra mandar direto do navegador do seu admin sem passar a sua sk_ pra frente.
Se o ambiente não tiver storage, `presign` responde 503
503 storage_unconfigured quer dizer que aquele ambiente não tem storage de
objeto configurado — não que você errou a chamada. Nesse caso use kind: "link":
o resto do fluxo (grant, e-mail, download) é idêntico nos dois casos.
O que acontece quando o pagamento confirma
Nessa ordem, e sem você fazer nada:
- A Infi resolve o comprador e o entregável daquela fatura.
- Cria um grant: um token único pra aquele pagamento.
- Manda o e-mail, assunto
Seu acesso / Your download is ready, com o link.
Se o mesmo evento for reprocessado, ele reencontra o grant e não manda um
segundo e-mail — a garantia é no banco (UNIQUE (payment_id, deliverable_id)),
não na sorte.
Produto sem entregável não é erro: a entrega simplesmente não faz nada.
Servir você mesmo (recomendado)
Não dependa da caixa de entrada do comprador. Você tem o link:
const grants = await infi.invoices.deliverable(invoiceId);
// [{ paymentId, token, downloadUrl, emailSentAt, createdAt }]
if (grants.length > 0) {
mostrarBotao(grants[0].downloadUrl); // sua página de obrigado
}Enquanto a fatura não foi paga, isso devolve 200 { "grants": [] } — lista
vazia, nunca 404. Isso é de propósito: "ainda não entreguei" é um estado
real que você fica consultando, e um 404 não daria pra distinguir de id
errado. Então é seguro colocar num loop de polling, ao lado do
pay.waitForPaid.
Três motivos pra preferir esse caminho ao e-mail:
- O e-mail pode simplesmente não sair, e o
emailSentAté como você sabe. Ele fica nulo quando o envio não aconteceu — e o caso que mais pega gente testando é endereço que não existe de verdade. Medido:@example.com(domínio reservado, nenhum provedor entrega) →emailSentAtnulo pra sempre; endereço real → preenchido em ~8s. O grant nasce nos dois casos, então a venda está entregável mesmo quando o e-mail não foi. - Comprador sem e-mail nenhum também gera grant. Sem essa chamada, a venda fica entregue pela metade e você não sabe.
- O e-mail pode cair em spam. A sua página de obrigado, não.
Essa rota exige a chave secreta
invoices.deliverable roda com sk_, escopo billing:read — nunca com uma
chave publicável. O token é uma credencial: quem tem ele baixa o produto,
sem mais nenhuma prova de compra. Por isso ele também não aparece na resposta
pública da fatura: id de fatura viaja em URL, histórico de navegador e ticket de
suporte, e o download não pode viajar junto. Busque no seu servidor e entregue
pro navegador que você acabou de cobrar.
O link de download
GET /pay/{slug}/download/{token}
Público, sem header de auth — o token é a credencial. Responde 302:
| entregável | pra onde redireciona |
|---|---|
link | a URL que você salvou |
file | uma URL assinada e nova do storage, válida por 5 minutos |
Token desconhecido responde 404. No SDK, se você já tem o token,
infi.pay.downloadUrl(slug, token) monta essa URL.
A URL assinada de download é curta de propósito (5 min) porque ela é gerada a cada clique: quem compartilhar o link assinado compartilha algo que expira. O que não expira é o token — veja abaixo.
O link não expira e não tem limite de uso
Hoje o grant não tem validade nem contador: quem tiver o token baixa quantas
vezes quiser, pra sempre. Ele é pessoal por ser secreto, não por ser verificado.
Trate como senha — não jogue em log, não coloque em URL que você compartilha. Se
o seu produto exige controle de acesso de verdade, use kind: "link" apontando
pra uma área que você mesmo autentica.
Trocar e remover
await infi.products.deliverable.get(productId); // o que está anexado hoje
await infi.products.deliverable.save(productId, {…}); // substitui
await infi.products.deliverable.delete(productId); // remove (idempotente)delete também apaga o arquivo do storage. Grants já emitidos deixam de
resolver — o comprador de ontem perde o acesso, então troque com save em vez
de deletar quando a ideia é publicar uma versão nova.
Fluxo completo
import { Infi } from "@beinfi/sdk";
const infi = new Infi({ secretKey: process.env.INFI_SECRET_KEY! });
// uma vez, ao cadastrar o produto
await infi.products.deliverable.save(productId, {
kind: "link",
url: "https://seusite.com/guia.pdf",
});
// a cada venda
const { invoiceId } = await infi.checkout({
slug,
productId,
customer: { externalId: "u_1", email: "[email protected]", taxId: "52998224725" },
idempotencyKey: `venda:${pedidoId}`,
});
const pago = await infi.pay.waitForPaid({ slug, invoiceId, timeoutMs: 15000 });
if (pago) {
// A entrega roda DEPOIS do pagamento confirmar, então o grant aparece um
// instante depois de `paid`. Uma leitura só devolve [] e você mostra uma
// página de obrigado sem download. Faça o 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 };
}O taxId não é opcional pra Pix, e o idempotencyKey evita cobrar duas vezes
no clique duplo — os dois estão explicados em
primeira venda.
E se você estornar?
O grant é uma capacidade sem prazo: quem tem o token baixa. Um estorno total
desliga ele — o link passa a responder 410 e o grant volta com revokedAt —
e um estorno parcial não. A regra inteira, incluindo como sobrescrever, está em
reembolso.
Isso vale só pro arquivo. Se o seu produto dá acesso a outra coisa (área de
membros, cargo no Discord, chave de API), quem corta é você: assine
payment.refunded e leia accessRevoked.