Case study

Integração real: EscolaNutri × Refera.

EscolaNutri é um SaaS de nutrição escolar (.NET 8 + SQL Server) que roda um programa de indicação com 5% de comissão em todos os planos. Este documento mostra o passo-a-passo completo dessa integração — do provisionamento do tenant até o webhook de partner.created que auto-cria conta no EscolaNutri. Serve como blueprint para outros SaaS.

Panorama

Duas portas de entrada pra virar parceiro, um fluxo de venda, três webhooks de volta. Cada diagrama abaixo é um caminho independente:

Fluxo A · parceiro cadastra no Refera
Site EscolaNutriclica "Quero ser parceiro"/signup/vendedor no Referapreenche email + senhapartner criado no Referareferral_code gerado, linka campaign_sellerswebhook partner.createdPOST /refera/webhook (EN backend)HMAC-SHA256 verified + idempotency checkAuto-provisiona conta no ENIdentityUser + role Partner + Customer + email resetParceiro pronto (sem senha ainda)
Fluxo B · parceiro cadastra no EscolaNutri
App EscolaNutriPOST /partner/registerEN cria IdentityUser + CustomerAspNetUsersId, Status=ACTIVE, role PartnerReferaService.CreatePartnerAsyncPOST /api/v1/partners (Refera)Bearer rfr_live_... + campaign_idRefera cria partner + referral_codelinka em campaign_sellers, retorna 201grava referral_code em CustomerEN atualiza Customer.ReferralCodewebhook partner.created (retorno)POST /refera/webhook (EN)detecta Customer com ReferralCode → skip
Fluxo C · venda gera comissão
Stripe / PagBankwebhook charge.succeededStripeWebhookController (EN)PartnerService.AccrueCommissionAsyncReferaService.NotifySaleAsyncPOST /api/v1/sales (Refera)referral_code + campaign_id + plan_id + amount_brlcreateSale valida + insere campaign_salesgera commissions row (status=available)201 Created{ sale_id, commission_id, amount_brl }sale.createdcommission.available/refera/webhook/refera/webhook

1. Setup inicial

Tenant + programa + campanha

No dashboard do Refera, o EscolaNutri criou:

  • 1 tenant chamado "EscolaNutri"
  • 1 program ativo (comissão 5%)
  • 1 campaign com 12 planos (ESS3/6/12, PRO3/6/12, CON3/6/12, WL3/6/12) — cada plano com o UUID correspondente ao SKU do EscolaNutri
  • 1 api_key com scope partners:write, sales:write
  • 1 webhook_endpoint apontando pra https://api.escolanutri.com.br/refera/webhook, escutando partner.created

Config no backend .NET

json·appsettings.Production.json
{
  "Refera": {
    "BaseUrl": "https://refera.com.br",
    "ApiKey": "rfr_live_XXXXXXXXXXXXXXXXXXXXXXXX",
    "WebhookSecret": "<segredo do webhook_endpoint>",
    "CampaignId": "c64d34c8-727e-47fa-a0bc-761737db03d7",
    "PlanIds": {
      "ESS3": "b0f1f2cf-881a-42a6-9bdf-97bf8628b133",
      "ESS6": "605aff35-ffcc-4e00-842f-7db753a2f685",
      "ESS12": "f06388e8-a9ca-4783-8b86-16b0118d00dd"
      /* … demais planos */
    }
  }
}

Fluxo A — parceiro nasce no Refera

Quando o usuário clica em "Quero ser parceiro" no site do EscolaNutri, ele vai pra /signup/vendedor do Refera, preenche email + senha e vira um partner lá. Refera dispara partner.created. O receiver do EscolaNutri auto-provisiona a conta no lado deles.

Receiver do webhook (.NET 8)

csharp·ReferaWebhookController.cs
[ApiController]
[Route("refera/webhook")]
public class ReferaWebhookController : ControllerBase
{
    // ... injeções via ctor (UnitOfWork, UserManager, RoleManager,
    // CustomerService, EmailService, EscolaNutriContext, IConfiguration, ILogger)

    [HttpPost]
    [AllowAnonymous]
    public async Task<IActionResult> Index(CancellationToken cancellationToken)
    {
        var secret = _configuration.GetValue<string>("Refera:WebhookSecret");

        string body;
        using (var reader = new StreamReader(Request.Body, Encoding.UTF8, leaveOpen: true))
            body = await reader.ReadToEndAsync(cancellationToken);

        var signature = Request.Headers["X-Refera-Signature"].FirstOrDefault();
        if (!VerifyHmac(body, secret, signature ?? "")) return Unauthorized();

        using var doc = JsonDocument.Parse(body);
        var root = doc.RootElement;
        var eventType = root.GetProperty("event").GetString();
        var eventId   = root.GetProperty("id").GetString();
        var data      = root.GetProperty("data");

        // Idempotência: INSERT-if-not-exists.
        // Se affected == 0, já processamos — retorna 200 e sai.
        if (await AlreadyProcessedAsync(eventId, eventType, cancellationToken))
            return Ok(new { duplicate = true });

        // CancellationToken.None: handler é pesado (SQL + email);
        // se Refera abortar o request, não deixa EF Core cancelar SaveChanges mid-way.
        if (eventType == "partner.created")
            await HandlePartnerCreatedAsync(data, CancellationToken.None);

        return Ok();
    }
}

Tabela de idempotência (SQL Server)

sql·ReferaProcessedEvent.sql
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ReferaProcessedEvent')
BEGIN
    CREATE TABLE ReferaProcessedEvent (
        EventId    NVARCHAR(255) NOT NULL PRIMARY KEY,
        EventType  NVARCHAR(100) NULL,
        ReceivedAt DATETIMEOFFSET NOT NULL DEFAULT SYSDATETIMEOFFSET()
    );
    CREATE INDEX IX_ReferaProcessedEvent_ReceivedAt
        ON ReferaProcessedEvent (ReceivedAt);
END
csharp
private async Task<bool> AlreadyProcessedAsync(string eventId, string eventType, CancellationToken ct)
{
    var affected = await _context.Database.ExecuteSqlInterpolatedAsync($@"
        INSERT INTO ReferaProcessedEvent (EventId, EventType, ReceivedAt)
        SELECT {eventId}, {eventType}, SYSDATETIMEOFFSET()
        WHERE NOT EXISTS (SELECT 1 FROM ReferaProcessedEvent WHERE EventId = {eventId});", ct);
    return affected == 0;
}

Auto-provisioning de Customer

Se o parceiro não tem Customer no EscolaNutri ainda, o handler criaIdentityUser + role Partner + Customer com o ReferralCode do Refera, e dispara email de "definir senha".

csharp
private async Task HandlePartnerCreatedAsync(JsonElement data, CancellationToken ct)
{
    var email = data.GetProperty("email").GetString();
    var code  = data.GetProperty("referral_code").GetString();
    var displayName = data.TryGetProperty("display_name", out var dn) ? dn.GetString() : null;

    var customer = await _unitOfWork.Customers.Items
        .Where(x => x.Email == email)
        .Select(x => new { x.Id, x.ReferralCode })
        .FirstOrDefaultAsync(ct);

    if (customer != null)
    {
        if (!string.IsNullOrWhiteSpace(customer.ReferralCode)) return; // já linkado

        await _unitOfWork.ExecuteSqlRawAsync(
            "UPDATE [Customer] SET ReferralCode = {0}, LastUpdatedAt = SYSDATETIMEOFFSET() WHERE Id = {1}",
            ct, code, customer.Id);
        return;
    }

    // Cria IdentityUser + Customer + envia email de definir senha.
    var tempPassword = GenerateTempPassword();  // 12+ chars, digit + non-alnum
    var user = new IdentityUser { Email = email, UserName = email, LockoutEnabled = false };
    await _userManager.CreateAsync(user, tempPassword);
    await _userManager.AddToRoleAsync(user, UserRoles.Partner);

    await _customerService.CreateAsync(new CustomerModel {
        Id = user.UserName, Name = displayName ?? email, Email = email,
        AspNetUsersId = user.Id, Status = StatusType.ACTIVE, ReferralCode = code,
    }, ct);

    var resetToken = await _userManager.GeneratePasswordResetTokenAsync(user);
    await _emailService.SendRecoveryPasswordEmailAsync(user, resetToken, ct);
}

Fluxo B — parceiro nasce no EscolaNutri

Se o usuário se registrou pelo endpoint POST /partner/registerdo EscolaNutri, o backend chama o Refera pra criar o partner do lado deles e recupera o referral_code. Depois disso, o Refera disparapartner.created de volta — o receiver acima detecta que o Customer já existe e apenas grava o ReferralCode.

csharp·ReferaService.cs (excerpt)
public async Task<string> CreatePartnerAsync(string email, string displayName, CancellationToken ct)
{
    var payload = new {
        email,
        display_name = displayName,
        campaign_id = _config["Refera:CampaignId"]
    };

    var req = new HttpRequestMessage(HttpMethod.Post, "/api/v1/partners") {
        Content = JsonContent.Create(payload),
    };
    req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _config["Refera:ApiKey"]);

    var resp = await _http.SendAsync(req, ct);
    resp.EnsureSuccessStatusCode();
    var json = await resp.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: ct);
    return json.GetProperty("referral_code").GetString()!;
}

Fluxo C — venda registrada

Quando o Stripe (ou PagBank) confirma pagamento, o EscolaNutri chamaPOST /api/v1/sales no Refera. A venda gera comissão automática e dispara os webhooks sale.created +commission.available.

csharp·ReferaService.NotifySaleAsync
public async Task NotifySaleAsync(
    string referralCode, string planAcronym, decimal amountBrl,
    string externalRef, CancellationToken ct)
{
    var planId = _config[$"Refera:PlanIds:{planAcronym}"];
    var payload = new {
        referral_code = referralCode,
        campaign_id   = _config["Refera:CampaignId"],
        plan_id       = planId,
        amount_brl    = amountBrl,
        external_ref  = externalRef,  // idempotência (Stripe session id, PagBank txid, etc)
    };

    var req = new HttpRequestMessage(HttpMethod.Post, "/api/v1/sales") {
        Content = JsonContent.Create(payload),
    };
    req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _config["Refera:ApiKey"]);
    var resp = await _http.SendAsync(req, ct);
    resp.EnsureSuccessStatusCode();
}

Resposta típica (201 Created):

json
{
  "sale_id": "a8f2c341-...",
  "commission_id": "d1b78e05-...",
  "commission_amount_brl": 5.20,
  "idempotent": false
}

A comissão nasce com status available. O vendedor vê saldo no dashboard e pode solicitar PIX.

Testando localmente

1. Configurar webhook_endpoint apontando pra localhost

sql
insert into refera.webhook_endpoints (tenant_id, url, secret, events, is_active)
values (
  '<seu-tenant-uuid>',
  'http://localhost:5002/refera/webhook',
  'dev-refera-webhook-secret-change-me',
  ARRAY['partner.created','sale.created','commission.available']::text[],
  true
);

Em dev, o retry via pg_cron não alcança localhost(roda no Supabase, us-west-2). Só o inline first-try funciona — o que é OK pra testar o happy path.

2. Disparar partner.created

bash
curl -X POST http://localhost:3003/api/v1/partners \
  -H "Authorization: Bearer rfr_live_XXX" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "[email protected]",
    "display_name": "E2E Test",
    "campaign_id": "c64d34c8-..."
  }'

3. Verificar delivery no Supabase

sql
select id, status, attempts, response_status, delivered_at, last_error
from refera.webhook_deliveries
where payload->'data'->>'email' = '[email protected]'
order by created_at desc;

4. Testar idempotência

Reenvie o MESMO evento (mesmo id) manualmente:

bash
BODY='{"id":"same-uuid","event":"partner.created", ...}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | tr -d ' \n' | sed 's/.*=//')
curl -X POST http://localhost:5002/refera/webhook \
  -H "X-Refera-Signature: $SIG" \
  -H "Content-Type: application/json" \
  -d "$BODY"
# → {"duplicate":true}

Gotchas do case

  • Timeout do fetch inline vs. handler pesado. Se o handler faz I/O em serviços remotos (SQL Server em outra região, envio de email), 5s é pouco. Subimos pro Refera pra 30s. Se seu handler é ainda mais pesado, responda 200 rápido e delegue pra worker interno.
  • Cancel do client cascateando em EF Core. O ASP.NET propaga oRequestAborted pra dentro do SaveChangesAsync. Se o Refera timeouta a request, o EF Core pode cancelar um insert no meio. Passe CancellationToken.None pra chamadas com side-effect no handler.
  • Senha temporária que passa em RequireNonAlphanumeric. Se seu Identity exige char não-alfanumérico e você gera senha só com base64, vai bater erro PasswordRequiresNonAlphanumeric. Adicione um ! ou similar ao gerar.
  • Ordem dos fluxos. Fluxo A e Fluxo B ambos disparampartner.created. O receiver deve ser idempotente pelos dois caminhos (Customer já existe / já tem ReferralCode / não existe).

Referências