Pular para o conteúdo

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.

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:

  1. A Infi resolve o comprador e o entregável daquela fatura.
  2. Cria um grant: um token único pra aquele pagamento.
  3. 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) → emailSentAt nulo 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.

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

Público, sem header de auth — o token é a credencial. Responde 302:

entregávelpra onde redireciona
linka URL que você salvou
fileuma 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.