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:
1. Setup inicial
Tenant + programa + campanha
No dashboard do Refera, o EscolaNutri criou:
- 1
tenantchamado "EscolaNutri" - 1
programativo (comissão 5%) - 1
campaigncom 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_keycom scopepartners:write, sales:write - 1
webhook_endpointapontando prahttps://api.escolanutri.com.br/refera/webhook, escutandopartner.created
Config no backend .NET
{
"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)
[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)
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);
ENDprivate 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".
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.
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.
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):
{
"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
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
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
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:
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 o
RequestAbortedpra dentro doSaveChangesAsync. Se o Refera timeouta a request, o EF Core pode cancelar um insert no meio. PasseCancellationToken.Nonepra 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 disparam
partner.created. O receiver deve ser idempotente pelos dois caminhos (Customer já existe / já tem ReferralCode / não existe).
Referências
- Autenticação — como gerar API keys.
- POST /sales — reference completa.
- Webhooks — retry, idempotência, formato de assinatura.