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

# Links e cupons

> Links rastreáveis, tokens e cupons de afiliado.

Cada Afiliado divulga **Links rastreáveis** no formato `/t/{token}` que registram o Clique e redirecionam o visitante ao destino, e pode ter **Cupons** de desconto que atribuem comissões em canais sem clique (podcast, evento, rádio). Esta página descreve os tokens globalmente únicos, deep linking, a whitelist de domínios de destino, a reserva de token por 12 meses e a sincronização de cupons com o gateway de pagamento.

O vínculo é sempre **Link → Afiliado → Programa**: um Link pertence a exatamente um Afiliado, que pertence a exatamente um Programa. O mesmo vale para o Cupom. O `programId` nunca é informado pelo cliente, e sim derivado do Afiliado.

## Links rastreáveis

Um Link tem o identificador `link_` + ULID e os campos principais:

| Campo            | Significado                                                                                                                           |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `token`          | Slug público em `/t/{token}`. 3 a 50 caracteres, `[a-z0-9-]`, case-insensitive (armazenado em minúsculas). **Único globalmente.**     |
| `subId`          | Sub-campanha do Afiliado (ex.: `newsletter`). String de 1 a 120 chars, opcional. Gravado no Clique; pode ser sobrescrito por `?sub=`. |
| `destinationUrl` | Deep link de destino. URL completa, opcional, restrita à whitelist da organização. Ausente ⇒ usa a `landingUrl` do Programa.          |
| `isDefault`      | Marca o Link padrão criado na aprovação do Afiliado (no máximo um por Afiliado).                                                      |
| `status`         | `active` ou `inactive`.                                                                                                               |

### Token globalmente único

O token é único entre **todos os Programas e todas as organizações**, não apenas dentro de um Programa. Isso é exigido pela rota pública `GET /t/{token}`, que resolve o Link **apenas pelo token** e descobre a organização a partir do Link encontrado.

<Warning>
  Um token já em uso por **outra organização** sempre retorna `409 conflict`, mesmo que o Link esteja inativo e com a reserva expirada. O conflito nunca revela a existência do Link de outra organização, e tokens de outra organização nunca são liberados.
</Warning>

Tokens são case-insensitive: `BRUNO-PROMO` e `bruno-promo` são o mesmo token. A entrada é normalizada para minúsculas antes da validação de unicidade e do redirect.

Ao criar um Link sem token custom, o token é gerado a partir do nome do Afiliado: acentos removidos, minúsculas, sequências não alfanuméricas viram hífen, hífens das pontas aparados, corte em 50 caracteres. Slugs com menos de 3 caracteres ganham o prefixo `aff-` (ex.: "Li" → `aff-li`). Em caso de colisão, são tentados alguns candidatos com sufixo aleatório (`<base>-<sufixo>`); esgotadas as tentativas, a criação falha com `409 conflict`.

### Link padrão automático

Todo Afiliado que entra no status `approved` recebe automaticamente um Link com `isDefault: true`, tanto na criação já aprovada quanto na primeira aprovação via mudança de status. A operação é **idempotente**: se o default já existe (ex.: re-aprovação), o Link existente é retornado sem criar outro. Veja [Afiliados](/docs/conceitos/afiliados).

### Sub-campanhas e deep linking

O `subId` segmenta campanhas do próprio Afiliado. Ele é gravado no Clique como sub-identificador padrão, mas pode ser sobrescrito por Clique via `?sub=` no redirect. No momento do Clique, o `subId` efetivo é `?sub= ?? link.subId`. Detalhes do redirect e do registro de Clique vivem em [Tracking](/docs/conceitos/tracking).

O `destinationUrl` permite enviar o visitante direto para uma página específica. Quando ausente, o redirect usa a `landingUrl` do Programa como fallback.

<Note>
  O `token` já identifica o Afiliado na URL `/t/{token}`. Para sobrescrever a sub-campanha na divulgação, acrescente `?sub=`. Exemplo de URL completa de um Afiliado: `https://api.userepass.com/t/bruno-promo?sub=newsletter`.
</Note>

### Whitelist de domínios de destino

Para impedir que o redirect do Repass seja usado como *open redirector*, todo `destinationUrl` precisa apontar para um domínio autorizado da organização. A whitelist é configurada por organização:

* Cada entrada autoriza o domínio exato **e** seus subdomínios (`example.com` autoriza `app.example.com`).
* Máximo de 50 domínios; normalizados para minúsculas e deduplicados na gravação.
* Whitelist vazia ou não configurada ⇒ qualquer `destinationUrl` é bloqueado com `403 not_allowed`.

A validação ocorre na criação e na atualização do Link. Limpar o `destinationUrl` (enviando `null` no `PATCH`) não consulta a whitelist e faz o redirect voltar a usar a `landingUrl` do Programa.

```bash theme={null}
curl https://api.userepass.com/settings/destination-domains \
  -X PUT \
  -H 'Authorization: Bearer rstr_...' \
  -H 'Content-Type: application/json' \
  -d '{ "domains": ["example.com", "loja.example.com"] }'
```

```json theme={null}
{ "domains": ["example.com", "loja.example.com"] }
```

### Ciclo de vida do Link

```mermaid theme={null}
stateDiagram-v2
    [*] --> active: criação (manual ou Link default na aprovação)<br/>emite link.created
    active --> inactive: POST /links/{linkId}/deactivate<br/>emite link.deactivated
    inactive --> active: POST /links/{linkId}/reactivate<br/>emite link.reactivated
    inactive --> inactive: token reivindicado por novo Link após 12 meses<br/>emite link.token_retired
    note right of inactive
        Reativável enquanto o token
        não tiver sido aposentado.
        Token reservado por 365 dias
        a partir de deactivatedAt.
    end note
```

* **`active`**: Link operacional. `/t/{token}` registra Clique e redireciona.
* **`inactive`**: Link desativado. `/t/{token}` responde `410 Gone`. O token fica **reservado por 12 meses** (365 dias exatos) a contar de `deactivatedAt`. Desativar um Link já inativo retorna `409 conflict`.

A desativação é reversível por uma rota dedicada: `POST /links/{linkId}/reactivate` volta o Link a `active` e limpa `deactivatedAt`, desde que o token ainda não tenha sido aposentado e o Afiliado não esteja no teto de Links ativos.

<Warning>
  O `token` é **imutável via API**. `PATCH /links/{linkId}` só aceita `subId` e `destinationUrl`. Não existe endpoint para trocar o token de um Link; o token só muda quando é aposentado após os 12 meses de reserva.
</Warning>

### Reserva de token por 12 meses

Tokens não são reutilizáveis de imediato. O token de um Link inativo da própria organização fica reservado por exatamente 365 dias a partir de `deactivatedAt`, evitando que um Afiliado herde tráfego residual de outro.

Quando um novo Link tenta reivindicar um token já existente, o resultado depende do estado atual do token:

| Situação do token                               | Resultado                                                                           |
| ----------------------------------------------- | ----------------------------------------------------------------------------------- |
| Token de **outra organização**                  | `409 conflict` (sempre, sem liberar)                                                |
| Token de Link **ativo** da própria organização  | `409 conflict` (`param: token`)                                                     |
| Token de Link **inativo**, reserva ainda válida | `409 conflict` ("reserved for 12 months")                                           |
| Token de Link **inativo**, reserva expirada     | O Link antigo é aposentado e o novo Link assume o token; emite `link.token_retired` |

Após a expiração, quando outro Link da **mesma organização** reivindica o token, o Link antigo é aposentado (liberando o slug) e o evento `link.token_retired` é emitido. Reativar um Link cujo token já foi aposentado retorna `409 conflict`: não há mais slug a restaurar.

### Limite de Links por Afiliado

Cada Afiliado pode ter no máximo `program.maxLinksPerAffiliate` Links no Programa (default **50**, configurável por Programa). A contagem considera apenas Links **ativos**: desativar libera vaga, reativar volta a ocupá-la (a reativação revalida o teto). Exceder o limite retorna `409 conflict`.

### Endpoints de Links

| Método  | Caminho                           | Idempotency-Key | Descrição                                        |
| ------- | --------------------------------- | --------------- | ------------------------------------------------ |
| `POST`  | `/affiliates/{affiliateId}/links` | sim             | Cria Link (token custom ou gerado).              |
| `GET`   | `/affiliates/{affiliateId}/links` | n/a             | Lista os Links do Afiliado (sem paginação).      |
| `PATCH` | `/links/{linkId}`                 | não             | Atualiza `subId`/`destinationUrl` de Link ativo. |
| `POST`  | `/links/{linkId}/deactivate`      | sim             | Desativa o Link (token reservado por 12 meses).  |
| `POST`  | `/links/{linkId}/reactivate`      | sim             | Reativa um Link inativo.                         |
| `GET`   | `/settings/destination-domains`   | n/a             | Lê a whitelist de domínios de destino.           |
| `PUT`   | `/settings/destination-domains`   | não             | Substitui a whitelist inteira.                   |

Todas as rotas exigem autenticação ([sessão ou API key](/docs/autenticacao)) e operam no escopo da organização ativa; nenhuma exige papel específico. Veja a aba Referência da API para o contrato completo de cada endpoint.

<CodeGroup>
  ```bash Criar Link de campanha theme={null}
  curl https://api.userepass.com/affiliates/aff_01J.../links \
    -X POST \
    -H 'Authorization: Bearer rstr_...' \
    -H 'Content-Type: application/json' \
    -d '{
      "token": "bruno-promo",
      "subId": "newsletter",
      "destinationUrl": "https://loja.example.com/precos"
    }'
  ```

  ```json Resposta 201 theme={null}
  {
    "id": "link_01J9Z6K2M8...",
    "affiliateId": "aff_01J...",
    "programId": "prog_01J...",
    "token": "bruno-promo",
    "subId": "newsletter",
    "destinationUrl": "https://loja.example.com/precos",
    "isDefault": false,
    "status": "active",
    "createdAt": "2026-06-13T12:00:00.000Z"
  }
  ```
</CodeGroup>

<Tip>
  `POST /affiliates/{affiliateId}/links` aceita corpo vazio (`{}`): nesse caso o token é gerado do nome do Afiliado e o Link redireciona para a `landingUrl` do Programa.
</Tip>

A listagem `GET /affiliates/{affiliateId}/links` retorna `{ "data": [...] }` com **todos** os Links do Afiliado (mais recentes primeiro), sem [paginação](/docs/convencoes/paginacao) por cursor: o teto prático é o `maxLinksPerAffiliate` do Programa.

### Eventos de Link

Cada mudança de estado emite um evento que você pode assinar via webhook ou consultar pela API de auditoria (veja [Event store](/docs/conceitos/event-store)):

| Tipo                 | Quando                                                                  |
| -------------------- | ----------------------------------------------------------------------- |
| `link.created`       | Criação (manual ou default automático).                                 |
| `link.updated`       | `PATCH` que efetivamente altera algum campo (payload traz só o diff).   |
| `link.deactivated`   | Desativação (payload: o `token` que entra em reserva).                  |
| `link.reactivated`   | Reativação de um Link inativo.                                          |
| `link.token_retired` | Token de Link inativo expirado reivindicado por novo Link da mesma org. |
| `settings.updated`   | `PUT /settings/destination-domains` (diff before/after).                |

## Cupons

O Cupom (`coup_` + ULID) é um **método de atribuição alternativo para canais sem clique**: quando uma Conversão chega informando o código do cupom (mesmo sem nenhum Clique rastreado), a Comissão é atribuída ao Afiliado dono do Cupom. Um Cupom pertence a no máximo um Afiliado; não há Cupom genérico sem dono.

| Campo                   | Tipo / unidade                                     | Significado                                      |
| ----------------------- | -------------------------------------------------- | ------------------------------------------------ |
| `code`                  | string, 3 a 50 chars `[A-Z0-9-]`, sempre maiúsculo | Código digitado no checkout. Único por Programa. |
| `discountType`          | `percentage` \| `fixed`                            | Tipo do desconto.                                |
| `discountPercentageBps` | inteiro em basis points (1% = 100 bps)             | Preenchido quando `discountType = percentage`.   |
| `discountAmountCents`   | inteiro em centavos                                | Preenchido quando `discountType = fixed`.        |
| `status`                | enum (ver abaixo)                                  | Estado de sincronização/uso.                     |
| `providerSync`          | JSON ou `null`                                     | Resultado da última sincronização com o gateway. |

<Note>
  Na criação via API, o desconto percentual é informado como decimal (`20` = 20%, 0,01 a 100, duas casas) e convertido para bps na persistência (`20` → `2000`). O desconto fixo é informado e persistido em centavos (inteiro ≥ 1). Na resposta, `discountValue` volta a decimal para percentuais e permanece em centavos para fixos.
</Note>

### Status do Cupom

* `pending`: recém-criado, ainda não sincronizado. Estado inicial, mas a criação encadeia a sincronização imediatamente, então raramente é observável.
* `active`: sincronizado com sucesso; **único status que atribui Conversões**.
* `sync_failed`: sincronização com o gateway falhou; o Cupom fica bloqueado até um resync bem-sucedido.
* `inactive`: desativado manualmente; não atribui mais.

### Sincronização com o gateway

Todo Cupom criado no Repass é **sincronizado automaticamente com o gateway de pagamento** (como o Stripe) para que o desconto exista de fato no checkout. O Cupom só fica utilizável (`active`) após a sincronização bem-sucedida.

```mermaid theme={null}
stateDiagram-v2
    [*] --> pending: Cupom criado
    pending --> active: sync OK (encadeado na criação)
    pending --> sync_failed: sync falha
    sync_failed --> active: resync → sync OK
    sync_failed --> sync_failed: resync → sync falha de novo
    active --> inactive: desativação
    sync_failed --> inactive: desativação
    pending --> inactive: desativação
    inactive --> [*]
```

A criação **sempre retorna `201`**, mesmo quando a sincronização falha: você distingue o resultado pelos campos `status` e `providerSync` na resposta. O Cupom nasce em `pending` e emite o evento `coupon.created`; em seguida o resultado da sincronização (`active` ou `sync_failed`) é aplicado, com seu próprio evento.

O objeto `providerSync` traz `provider`, `status` (`synced` ou `failed`), `syncedAt` (quando `synced`) e `error` (quando `failed`).

```json Resposta 201 (sync bem-sucedida) theme={null}
{
  "id": "coup_01J...",
  "affiliateId": "aff_01J...",
  "programId": "prog_01J...",
  "code": "BIA20",
  "discountType": "percentage",
  "discountValue": 20,
  "status": "active",
  "providerSync": { "provider": "stripe", "status": "synced", "syncedAt": "2026-06-13T12:00:00.000Z" }
}
```

```json Resposta 201 (sync falhou) theme={null}
{
  "id": "coup_01J...",
  "code": "BIA20",
  "status": "sync_failed",
  "providerSync": { "provider": "stripe", "status": "failed", "error": "gateway timeout" }
}
```

Um Cupom em `sync_failed` (ou `pending`) pode ser reenviado com `POST /coupons/{couponId}/resync`. Resync de um Cupom `active` ou `inactive` é rejeitado com `409 conflict`.

### Desativação

`POST /coupons/{couponId}/deactivate` leva qualquer Cupom (≠ `inactive`) para `inactive`, define `deactivatedAt` e propaga a desativação ao gateway. A desativação local acontece independentemente do resultado do gateway. Desativar um Cupom já `inactive` retorna `409 conflict`. **Não há reativação** de Cupom: uma vez `inactive`, não há transição de volta.

### Atribuição por Cupom

Na Conversão, o código é normalizado para maiúsculas e o Cupom precisa estar `active`. Cupom inexistente ou em qualquer outro status retorna `404` com `param: couponCode`. O conflito entre Cupom e Clique é resolvido pela política do Programa (`couponAttributionPolicy`):

```mermaid theme={null}
sequenceDiagram
    participant Conv as Conversão
    Note over Conv: Conversão chega com couponCode
    Conv->>Conv: busca cliques atribuíveis e o Cupom active
    alt sem clique vencedor
        Conv-->>Conv: atribui 100% ao Afiliado do Cupom (matchMethod=coupon)
    else clique vencedor do mesmo Afiliado do Cupom
        Conv-->>Conv: mantém atribuição por Clique, registra couponId
    else Cupom de A + Clique de B
        Conv-->>Conv: aplica couponAttributionPolicy do Programa
    end
```

* `coupon_wins` (default): 100% ao Afiliado do Cupom.
* `click_wins`: 100% ao Afiliado do Clique.
* `split_50_50`: divide 5000/5000 bps, com o Cupom como primário.

Os detalhes completos de precedência vivem em [Atribuição](/docs/conceitos/atribuicao) e [Conversões e fraude](/docs/conceitos/conversoes-e-fraude).

### Endpoints de Cupons

| Método | Caminho                             | Idempotency-Key | Descrição                                          |
| ------ | ----------------------------------- | --------------- | -------------------------------------------------- |
| `POST` | `/affiliates/{affiliateId}/coupons` | sim             | Cria Cupom e sincroniza com o gateway.             |
| `GET`  | `/coupons`                          | n/a             | Lista Cupons da organização (paginado por cursor). |
| `GET`  | `/coupons/{couponId}`               | n/a             | Retorna um Cupom com seu status de sincronização.  |
| `POST` | `/coupons/{couponId}/resync`        | sim             | Retenta a sincronização.                           |
| `POST` | `/coupons/{couponId}/deactivate`    | sim             | Desativa o Cupom.                                  |

A listagem `GET /coupons` usa [paginação](/docs/convencoes/paginacao) por cursor (`limit` 1 a 100, default 25; `starting_after`/`ending_before`) com filtros opcionais `program_id`, `affiliate_id`, `status`.

<CodeGroup>
  ```bash Criar Cupom percentual theme={null}
  curl https://api.userepass.com/affiliates/aff_01J.../coupons \
    -X POST \
    -H 'Authorization: Bearer rstr_...' \
    -H 'Content-Type: application/json' \
    -d '{ "code": "BIA20", "discountType": "percentage", "discountValue": 20 }'
  ```

  ```bash Criar Cupom fixo (centavos) theme={null}
  curl https://api.userepass.com/affiliates/aff_01J.../coupons \
    -X POST \
    -H 'Authorization: Bearer rstr_...' \
    -H 'Content-Type: application/json' \
    -d '{ "code": "BIA10OFF", "discountType": "fixed", "discountValue": 1000 }'
  ```

  ```bash Reenviar sincronização theme={null}
  curl https://api.userepass.com/coupons/coup_01J.../resync \
    -X POST \
    -H 'Authorization: Bearer rstr_...'
  ```
</CodeGroup>

### Eventos de Cupom

| Tipo                    | Quando                                                  |
| ----------------------- | ------------------------------------------------------- |
| `coupon.created`        | Ao inserir o Cupom (`pending`), antes da sincronização. |
| `coupon.sync_succeeded` | Sincronização bem-sucedida (na criação ou no resync).   |
| `coupon.sync_failed`    | Sincronização com o gateway falhou.                     |
| `coupon.deactivated`    | Ao desativar o Cupom.                                   |

## Erros comuns

<AccordionGroup>
  <Accordion title="409 conflict em token de Link">
    Token em uso por Link ativo, token de outra organização, token reservado (12 meses), limite de Links do Programa atingido (criação ou reativação), Link já desativado/ativo, ou token já aposentado na reativação. Conflitos de token trazem `param: "token"`.
  </Accordion>

  <Accordion title="403 not_allowed em destinationUrl">
    A whitelist de domínios está vazia ou o hostname não está autorizado. Configure a whitelist em `PUT /settings/destination-domains` antes de usar deep links.
  </Accordion>

  <Accordion title="409 conflict em Cupom">
    Código duplicado no Programa (`param: code`), Afiliado banido na criação, resync de Cupom `active`/`inactive`, ou desativar um Cupom já `inactive`.
  </Accordion>

  <Accordion title="404 resource_not_found">
    Afiliado inexistente na organização, Link/Cupom inexistente, ou (na Conversão) `couponCode` que não corresponde a um Cupom `active`.
  </Accordion>
</AccordionGroup>

Veja o envelope de erro padrão em [Erros](/docs/convencoes/erros) e o uso de `Idempotency-Key` em [Idempotência](/docs/convencoes/idempotencia).

## Próximos passos

<CardGroup cols={2}>
  <Card title="Tracking" icon="link" href="/docs/conceitos/tracking">
    Como o redirect `/t/{token}` registra o Clique e o cookie de visitante.
  </Card>

  <Card title="Atribuição" icon="bullseye" href="/docs/conceitos/atribuicao">
    Janelas, precedência e a política de conflito Cupom × Clique.
  </Card>

  <Card title="Afiliados" icon="users" href="/docs/conceitos/afiliados">
    Aprovação do Afiliado e criação do Link padrão automático.
  </Card>

  <Card title="Conversões e fraude" icon="receipt" href="/docs/conceitos/conversoes-e-fraude">
    Como uma Conversão com `couponCode` gera a Comissão.
  </Card>
</CardGroup>
