Sumá un club de afinidad a tu marketplace en horas, no meses.
PagoUno Affinity es la capa de fidelización + confianza con tu marca. Vos seguís siendo el dueño de la relación con tu cliente; nosotros ponemos los puntos, los niveles, el canje, el widget y la inteligencia antifraude. Una sola integración por API.
Conceptos clave
PersonId). El socio es la Persona, no la cuenta. Tu marketplace referencia siempre la PersonId.Quickstart — integrá en 4 pasos
ClubId.POST /admin/clubs/{clubId}/earn: el socio acumula puntos automáticamente (y se enrola solo si tu club es AUTO).https://affinity.pagouno.io · Emisor de tokens (IdP): https://auth.pagouno.ioAutenticación
Toda la API se protege con OAuth2. El backend de tu marketplace usa client_credentials (M2M) con el scope
affinity.admin. Te entregamos client_id y client_secret.
# 1) Obtené un access token (válido ~1h) curl -X POST https://auth.pagouno.io/connect/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=client_credentials" \ -d "client_id=TU_CLIENT_ID" \ -d "client_secret=TU_CLIENT_SECRET" \ -d "scope=affinity.admin" # → { "access_token": "eyJ...", "expires_in": 3600, "token_type": "Bearer" } # 2) Usalo como Bearer en cada request curl https://affinity.pagouno.io/admin/clubs \ -H "Authorization: Bearer eyJ..."
expires_in. Nunca expongas el client_secret en el front-end.Scopes
| Scope | Quién lo usa | Para qué |
|---|---|---|
| affinity.marketplace | Backend del marketplace | Reportar compras (earn), validar socio por DNI y operar vouchers en el checkout. El scope recomendado para integraciones — no administra clubes. |
| affinity.admin | BO / operaciones PagoUno | Configurar clubes, tiers, reglas, planes y socios (superset: también pasa por los endpoints de marketplace). |
| affinity.member | El socio (token CIAM) | Ver su membresía/puntos, el catálogo y canjear. Lo usa el widget. |
| affinity.trust.read | Motor antifraude | Leer la señal de confianza del socio (uso interno de PagoUno). |
Acumular puntos (earn)
Es el endpoint que más vas a usar: reportá cada compra confirmada y el motor acumula puntos según tu regla.
Si tu club es AUTO, el socio se enrola solo en su primera compra.
| Método | Endpoint | Scope |
|---|---|---|
| POST | /admin/clubs/{clubId}/earn | affinity.admin |
curl -X POST https://affinity.pagouno.io/admin/clubs/{clubId}/earn \ -H "Authorization: Bearer eyJ..." -H "Content-Type: application/json" \ -d '{ "personId": "7bd04518-47e5-4907-9df5-ee8e4fbe429a", "amount": 12500.00, "refId": "order_98412", "autoEnroll": true }' # → { "Result":"OK", "MembershipId":"...", "PointsEarned":125, "BalanceAfter":1250, "LifetimePoints":4300 }
refId (id de la orden) para poder conciliar. Result puede ser OK, NOT_MEMBER, NOT_ACTIVE o CLUB_NOT_FOUND.expiryMonths del club
(default 12 meses, FIFO). Es automático — no requiere integración de tu parte.Membresía & catálogo (socio)
Endpoints que consume el front del socio (token affinity.member). El widget ya los usa por vos.
| Método | Endpoint | Descripción |
|---|---|---|
| GET | /member/persons/{personId}/memberships | Todas las membresías del socio (cross-club). |
| GET | /member/memberships/{id} | Detalle: tier, saldo, antigüedad. |
| GET | /member/memberships/{id}/ledger | Historial de puntos. |
| GET | /member/clubs/{clubId}/catalog | Recompensas activas para canjear. |
| POST | /member/clubs/{clubId}/opt-in | El socio se suma (clubes OPT_IN). |
| GET | /public/clubs/{slug} | Sin token: marca del club (para tematizar). |
person_id
(lo emite el IdP). La API coteja que el personId/membershipId operado sea el del
token — pedir datos de otra persona devuelve 403. Usá siempre el personId del
socio logueado (el widget ya lo hace por vos).Canjear (redeem)
El socio canjea puntos por una recompensa. El motor valida saldo y stock, descuenta los puntos y emite el voucher.
| Método | Endpoint | Scope |
|---|---|---|
| POST | /member/memberships/{id}/redeem | affinity.member |
curl -X POST https://affinity.pagouno.io/member/memberships/{id}/redeem \ -H "Authorization: Bearer SOCIO_TOKEN" -H "Content-Type: application/json" \ -d '{ "rewardId": "a1b2c3d4-..." }' # → { "Result":"OK", "VoucherCode":"7F3A9C21B0E4", "PointsSpent":500, "BalanceAfter":750 }
VoucherCode para aplicar el descuento en TU checkout.
Si es catálogo PagoUno, el canje queda en REQUESTED y nosotros gestionamos el fulfillment.
Errores posibles (HTTP 422): INSUFFICIENT_POINTS, NO_STOCK, REWARD_INACTIVE.Señal de confianza (antifraude)
Cada socio acumula un score de confianza 0–100 a partir de su antigüedad, historial de compras limpio, nivel y amplitud entre clubes. La gracia: como la Persona es única en todo PagoUno, alguien con años de buen historial en otro club entra como bajo riesgo en tu marketplace aunque sea su primera compra.
| Método | Endpoint | Scope |
|---|---|---|
| GET | /trust/persons/{personId} | affinity.trust.read |
# Respuesta — solo el derivado normalizado, nunca PII ni "está en el club X" { "PersonId": "7bd04518-...", "TrustScore": 82, "TenureDays": 540, "MaxTierRank": 3, "CleanPurchases": 36, "Clubs": 2, "ConsentShared": true }
ConsentShared). Anti-gaming: un socio recién creado pesa ≈ 0.Admin de clubes (referencia)
Podés administrar todo desde el panel de PagoUno; también está disponible por API (scope affinity.admin).
| Método | Endpoint | Descripción |
|---|---|---|
| GET | /admin/clubs | Listar clubes (filtrá por ?tenantId=). |
| POST | /admin/clubs | Crear un club (nombre, slug, modo, branding, config de puntos/canje). |
| PUT | /admin/clubs/{clubId} | Editar el club. |
| POST | /admin/clubs/{clubId}/tiers | Crear/editar niveles (umbral + perks). |
| POST | /admin/clubs/{clubId}/rules | Reglas de acumulación (pts por $, multiplicador). |
| POST | /admin/clubs/{clubId}/rewards | Catálogo de recompensas. |
| GET | /admin/clubs/{clubId}/members | Listar socios (búsqueda + paginado). |
| GET | /admin/clubs/{clubId}/members/validate/{dni} | Validar un socio por DNI (puerta/POS/beneficios): devuelve estado, nro. de socio, nivel, saldo, nombre, email y flags de verificación (DNI y email) — o 404 si ese DNI no es socio del club. La validez la decidís vos con status/isActive. |
| POST | /admin/members/{id}/status | Activar/suspender un socio. |
| POST | /admin/redemptions/{id}/status | Marcar un canje como entregado. |
Widget embebible (white-label)
La forma más rápida de mostrarle el club al socio: tarjeta con su nivel, puntos, catálogo y canje — ya con tu marca (se tematiza solo desde la config del club). Pegá esto donde quieras que aparezca:
<div id="mi-club"></div> <script src="https://affinity.pagouno.io/widget/affinity-widget.js"></script> <script> AffinityWidget.mount(document.getElementById('mi-club'), { apiBase: 'https://affinity.pagouno.io', clubSlug: 'club-mimarca', personId: socio.personId, // la PersonId del socio logueado memberToken: socio.accessToken // token CIAM con scope affinity.member }); </script>
Inscripción pública (widget de alta)
Para que una persona nueva se sume al club desde tu sitio (a diferencia del widget de socio, que es para alguien ya logueado). Flujo: pide el email → manda un código OTP → al validarlo muestra el formulario → da de alta al socio. La estética sale de una plantilla HTML/CSS editable (la hospedás vos).
1) Embebé el plugin
<div id="club-tnc"></div> <script src="https://affinity.pagouno.io/widget/affinity-enroll.js"></script> <script> AffinityEnroll.mount(document.getElementById('club-tnc'), { apiBase: 'https://affinity.pagouno.io', publicKey: 'pk_xxxxxxxx', // la public key del club templateUrl: 'https://tusitio.com/plantilla-club.html' // tu plantilla (estética libre) }); </script>
.js lo podés servir desde un CDN (S3 + CloudFront) y pinnear una versión:
affinity-enroll-1.0.0.js (cachea inmutable). El apiBase siempre apunta a affinity.publicKey? Del Backoffice → Clubes → panel “Plugin de inscripción”
(ahí también se ven/editan los orígenes permitidos y el kill-switch), o por API con
POST /admin/clubs/{clubId}/enrollment (scope affinity.admin). Se genera una sola
vez y los updates no la rotan. No es secreta — puede ir en el HTML y en tu repo: el anti-abuso real es
OTP + CAPTCHA + rate-limit + orígenes permitidos.2) La plantilla (HTML/CSS libre + ganchos data-affinity-*)
La plantilla define el look; el plugin se engancha por atributos (no cambies sus nombres). Hay una de ejemplo en
/widget/plantilla-club.example.html.
| Atributo | Para qué |
|---|---|
data-affinity-step="email|otp|form|done|disabled" | Contenedor de cada paso (el plugin muestra/oculta) |
data-affinity-field="email|otp|displayName|dni|phone|hp" | Inputs base (hp = honeypot anti-bot, dejalo oculto) |
data-affinity-extra="clave" | Campos extra del club (los que quieras): cualquier input / select / radio / checkbox. Se guardan como JSON en el perfil del socio. Ver abajo 👇 |
data-affinity-action="send-otp|verify-otp|resend|enroll|restart" | Botones de cada acción |
data-affinity-captcha | Contenedor del CAPTCHA (Cloudflare Turnstile) |
data-affinity-msg · data-affinity-club-name | Zona de mensajes · nombre del club (se rellena solo) |
3) Campos extra del formulario (perfil del socio)
Además del email (verificado) y el DNI, tu club seguramente pide más datos en la inscripción
(apellido, nombre, fecha de nacimiento, provincia, intereses, etc.). Agregalos en tu plantilla con
data-affinity-extra="clave" y el plugin los junta automáticamente y los guarda como un
JSON en el perfil del socio (ProfileJson), para que después se los puedas
reportar/exportar al club.
<!-- en el paso data-affinity-step="form" --> <input type="text" data-affinity-extra="apellido"> <input type="text" data-affinity-extra="nombre"> <input type="date" data-affinity-extra="fechaNacimiento"> <select data-affinity-extra="provincia">…</select> <!-- radio: el plugin toma la opción marcada --> <input type="radio" name="interesEmail" value="Sí" data-affinity-extra="interesEmail"> <input type="radio" name="interesEmail" value="No" data-affinity-extra="interesEmail">
Eso queda guardado así en el perfil del socio:
{
"apellido": "Pérez",
"nombre": "Juan",
"fechaNacimiento": "1990-05-12",
"provincia": "Buenos Aires",
"interesEmail": "Sí"
}- Soporta
input(text/email/date/tel…),select,radio(toma el marcado) ycheckbox. - Si no ponés un
data-affinity-field="displayName"pero sí los extranombrey/oapellido, el plugin arma el nombre con esos (así podés tener Nombre/Apellido separados). - Límites por inscripción: hasta 30 campos, valor de hasta 500 caracteres; los vacíos se descartan.
- Plantilla de ejemplo con el formulario completo del TNC:
/widget/plantilla-club.example.html.
4) Credenciales y anti-abuso
- Public key por club: identifica el club (va en el JS, no es secreta). Se provisiona con
POST /admin/clubs/{clubId}/enrollment(orígenes permitidos + on/off) y permite cortar un solo club ante abuso (kill-switch). - CAPTCHA (Turnstile): obligatorio en producción — sin él, el endpoint público de OTP es abusable. El plugin lo renderiza solo si está configurado.
- Rate-limit por IP/email/club + OTP obligatorio (sin código válido no hay alta) + respuestas uniformes (no filtran si un email existe).
templateUrl tiene que permitir CORS (o estar en el mismo origen que tu página). El <script> del plugin no necesita CORS.5) Credencial digital (Apple Wallet / Google Wallet)
Al enrolar, el socio recibe una credencial digital con el nombre, DNI, número de socio, puntos y QR — brandeada con los colores del club — que puede guardar en Apple Wallet o Google Wallet. Los puntos se actualizan solos en el teléfono cuando el socio acumula o canjea.
- El mail de bienvenida incluye el botón “Agregar a Apple / Google Wallet” automáticamente.
- La respuesta del alta (
POST /public/enroll/{publicKey}/submit) ahora traewalletUrl(además destatus: "enrolled"): mostrala en tu pasodonesi querés ofrecer el botón ahí mismo. Si la credencial no está disponible, vienenull— el alta funciona igual. - El branding sale del
BrandingJsondel club:primaryColor(fondo),accentColor(etiquetas),logoUrlystripUrl(imagen superior, PNG por HTTPS).
Cobro recurrente (webhook)
Para las membresías pagas, PagoUno orquesta el ciclo (agenda, factura, reintentos)
y tu sitio ejecuta el cobro. Cuando vence un período, affinity hace un POST firmado
a tu webhook de cobro; vos cobrás contra tu PSP y respondés el resultado. Configurás la URL y el
secreto por club en el panel (Clubes → Cobro).
# Lo que affinity POSTea a tu webhook (header de firma incluido): POST https://tusitio.com/affinity/charge X-Affinity-Signature: <HMAC-SHA256 hex del body crudo, con el secreto del club> { "invoiceId": "...", "subscriptionId": "...", "clubId": "...", "personId": "...", "amount": 1500.00, "currency": "ARS", "paymentRef": "tok_visa_4242", "periodStart": "2026-07-01T00:00:00Z", "periodEnd": "2026-08-01T00:00:00Z" } # Tu respuesta (sincrónica): { "result": "PAID", "chargeRef": "ch_abc123" } # o, si no pudiste cobrar: { "result": "FAILED", "reason": "card_declined" }
Verificá la firma (referencia, Node)
app.post('/affinity/charge', (req, res) => { const sig = crypto.createHmac('sha256', CLUB_SECRET) .update(req.rawBody).digest('hex'); if (sig !== req.headers['x-affinity-signature']) return res.status(401).end(); const r = await psp.charge(req.body.paymentRef, req.body.amount, req.body.currency); res.json(r.ok ? { result: 'PAID', chargeRef: r.id } : { result: 'FAILED', reason: r.error }); });
POST /billing/callback con { invoiceId, result, chargeRef?, reason? } y la misma
firma X-Affinity-Signature sobre el body.FAILED, affinity reintenta según el plan
(reintentos + días de gracia configurables); agotados, suspende la membresía. No tenés que manejar reintentos.Canje en tu checkout (vouchers)
Cuando un socio canjea una recompensa de tipo voucher de marketplace, recibe un
VoucherCode. En tu checkout: validás el voucher, aplicás el beneficio y lo
consumís para que no se reuse. (M2M, scope affinity.admin.)
| Método | Endpoint | Descripción |
|---|---|---|
| GET | /vouchers/{code} | Validar: devuelve Valid, VoucherKind (PERCENT/FIXED/ITEM), VoucherValue y estado. |
| POST | /vouchers/{code}/consume | Marcar usado al aplicarlo. Idempotente (ALREADY_USED si ya se usó). |
# 1) validar al ingresar el código en el checkout curl https://affinity.pagouno.io/vouchers/7F3A9C21B0E4 \ -H "Authorization: Bearer eyJ..." # → { "Valid": true, "VoucherKind": "PERCENT", "VoucherValue": 20, "RewardName": "20% OFF", "Status": "ISSUED" } # 2) aplicás el 20% en tu checkout y lo consumís curl -X POST https://affinity.pagouno.io/vouchers/7F3A9C21B0E4/consume \ -H "Authorization: Bearer eyJ..." -H "Content-Type: application/json" \ -d '{ "fulfillmentJson": "{\"orderId\":\"98412\"}" }' # → { "Result": "OK", "Status": "FULFILLED" }
Valid: true antes de aplicar, y consumí después de
confirmar la compra. Si consume devuelve ALREADY_USED, no apliques el beneficio.Errores
| Código | Significado |
|---|---|
| 200 | OK. Las operaciones de negocio devuelven además un campo Result. |
| 401 | Token ausente, vencido o inválido. Pedí uno nuevo. |
| 403 | El token no tiene el scope necesario. |
| 404 | El recurso (club, membresía, recompensa) no existe. |
| 422 | Regla de negocio: saldo insuficiente, sin stock, recompensa inactiva. |
| 5xx | Error nuestro. Reintentá con backoff; nunca lo trates como rechazo de negocio. |
Checklist de go-live
client_id/secret (M2M) y tu ClubId./earn al confirmar cada compra.VoucherCode en tu checkout.