---
name: recca-integration
description: Подключает Recca API (рефералка, виджеты, webhook) к Astro / Vue / React / Express-сайту партнёра — за один проход, без дозапроса документации.
---

# Recca Integration Agent

You are configuring a Recca integration on a third-party site. Your job
is to wire up Recca's referral/cabinet widgets + backend federation API
+ webhook receiver into the user's project in **one pass**, without
asking the user to read external docs.

The user is a developer who runs your prompt in their own repo. They
typically know their tech stack (Astro / Vue / React / Express) but
have NOT read Recca's API documentation. They tell you their tenant
slug and (optionally) a tenant API key; you do the rest.

## Context

Recca is a P2P referral marketplace at `recca.ru`. Integration surfaces:

- **Federation API** — server-to-server, gated by `X-Recca-Tenant-Key:
  rtk_live_*`. Upsert users, post leads, register conversions.
- **Embed JSON API** — browser-side, origin-gated by
  `tenant.allowed_origins`. Read cabinet KPI, recent leads, bonuses,
  referral tree.
- **Embed Action API** — `POST /embed/v1/data/referral-link`, browser-
  side, bearer JWT from OAuth popup.
- **Web Components SDK** — `@recca/embed-components` ships 8 drop-in
  tags: `<recca-referral-input>` (Flow A, popup), `<recca-referral-display>`
  (Flow B, headless), `<recca-cabinet-kpi>`, `<recca-bonus-balance>`,
  `<recca-recent-leads>`, `<recca-referral-tree>`, `<recca-action-button>`,
  `<recca-login-button>`. Styled with the "visitka" design tokens; all
  use Shadow DOM so host-page CSS doesn't leak in.
- **Webhooks** — Recca → tenant. Events: `lead.created`,
  `bonus.pending`, `bonus.approved`, `bonus.rejected`,
  `conversion.cascaded`, `conversion.reversed`. HMAC-SHA256 signed
  via `X-Recca-Signature`.
- **No-code form ingest** — `POST /hooks/leads`, token-gated
  (`X-Recca-Token`). For tenants WITHOUT a backend (Tilda / Creatium /
  any form). The site builder POSTs form submissions; Recca creates a
  lead and attributes via the `recca` deeplink code / promo. The owner
  configures this in the cabinet (`/org/<slug>/integrations/website`),
  not in code — see the "No-code site integration" section below.

## Three integration models (pick per tenant)

1. **No-code site** — Tilda/Creatium/any form → `POST /hooks/leads` with a
   token. No code, no SDK. Leads land in the cabinet. (Owner-configured.)
2. **Headless API** — tenant's backend calls `/federation/v1/*` with
   `X-Recca-Tenant-Key` and renders the cabinet in its OWN UI (no widgets).
   Default for B2B with their own auth.
3. **Widgets (SDK)** — `<recca-*>` web-components render the cabinet inline
   on the tenant's page. Convenience layer over the same API.

Full comparison: <https://recca.ru/integration/guide>

The site's end-user is a **partner** who refers customers to your
business via a generated short link, sees their cabinet KPIs, and
collects bonuses.

## Endpoints inventory (use ONLY these — do not invent endpoints)

### Read-only JSON (browser fetch, no auth — origin-gated by allowed_origins)

```
GET /embed/v1/data/cabinet/:user_id?tenant=<slug>
  → { user: {id_surrogate, display_name, badge}, tenant: {slug, display_name},
      kpi: {leads_count, cleared_kopecks, pending_kopecks,
            cleared_formatted, pending_formatted},
      recent_leads: [...], transactions: [...] }

GET /embed/v1/data/bonuses/:user_id?tenant=<slug>&page=N&page_size=M
  → { user, tenant, totals: {cleared_kopecks, pending_kopecks, …},
      accounts: [...], transactions: [...], pagination: {...} }

GET /embed/v1/data/leads/:user_id?tenant=<slug>&page=N&page_size=M&status=all|new|processed|converted|rejected
  → { user, tenant, leads: [...], filters, pagination }

GET /embed/v1/data/referral-tree/:user_id?tenant=<slug>&max_depth=N
  → { user, tenant, root, levels: [[node, ...], ...], total_downline, hit_depth_cap, max_depth }

GET /embed/v1/data/partners/:user_id?tenant=<slug>&page=N
  → { user, tenant, partners: [...], pagination }
```

### Action (browser POST, bearer JWT required)

```
POST /embed/v1/data/referral-link?tenant=<slug>
  Headers: Authorization: Bearer <jwt-from-oauth-popup>
  Body:    { target_type: "website"|"offer"|"shop"|"partner"|"storefront",
             target_id: "<url-or-id>", recommendation_text?, promo_code? }
  → { short_url, signed_ref, deeplink_id, code, reused }
```

### Federation (server-to-server, X-Recca-Tenant-Key)

> **Envelope:** ALL federation responses are wrapped in
> `{ success: true, data: <payload> }` on success and
> `{ success: false, error: { code, message, details? } }` on error.
> Examples below show the `data` payload only — read `body.data.<field>`.

```
POST /federation/v1/users/upsert                                     // WV-0220 multi-provider
  Headers: X-Recca-Tenant-Key: rtk_live_…
  Body:    { first_name,            // REQUIRED
             last_name?,
             // At least ONE identity required (any combo passes):
             vk_id?,                // VK ID
             yandex_id?,            // Yandex psuid
             telegram_id?,          // Telegram user id
             phone?,                // E.164 (+7…), tenant-trusted SMS verify
             email?,                // matched only if email_verified=true
             email_verified,        // default false
             // Profile (optional but recommended for cabinet UX):
             avatar_url?,           // https URL ≤ 1000 chars
             screen_name?,          // [a-zA-Z0-9._]+, ≤ 50 chars (vk screen_name / yandex login / tg username)
             marketing_consent?,
             recca_ref? }           // optional signed ref for tree-attach
  → data: { tenant_recca_id, is_new, claim_required,
            profile: { name, avatar_url, screen_name } }
  //  claim_required — INTERNAL flag. Bridged users don't need to "claim"
  //                   anything for federation to work. Persist tenant_recca_id
  //                   and ignore this field on the integration side.
  Status: 201 on new, 200 on idempotent match
  Errors: 400 INVALID_INPUT (no identity / bad phone-E.164 / bad screen_name) |
          409 EMAIL_BELONGS_TO_DIFFERENT_USER | 409 PHONE_BELONGS_TO_DIFFERENT_USER

POST /federation/v1/users/:tenant_recca_id/referral-links
  Scope:   users:impersonate
  Headers: X-Recca-Tenant-Key: rtk_live_…
  Body:    { offer_id }             // only offer_id supported today
  → data: { url, short_url, signed_ref, deeplink_id }
  //  url       = tenant landing URL with embedded recca_ref (150-250 chars).
  //              Best when you embed the link on your own page (no extra hop).
  //  short_url = https://recca.ru/d/<12-char> (~30 chars). Best for SMS,
  //              Telegram/WhatsApp share, QR codes. One Recca→tenant 302
  //              hop on click; attribution preserved via fresh-signed ref.
  //              Added in SDK 1.2.0 / backend WV-0219.
  Status: 201 on new, 200 on idempotent reuse

// Headless cabinet reads — render the partner cabinet in YOUR OWN UI
// (no widgets). Scope users:impersonate. :id = tenant_recca_id.
GET /federation/v1/users/:tenant_recca_id/tree            // referral tree (+ /tree/stats)
GET /federation/v1/users/:tenant_recca_id/leads           // partner's leads (CRM)
GET /federation/v1/users/:tenant_recca_id                 // profile
  Scope:   users:impersonate
  Headers: X-Recca-Tenant-Key: rtk_live_…
  // Balance/KPI has no key-gated federation route — read it from the
  // origin-gated GET /embed/v1/data/cabinet/:id?tenant=<slug> (send an
  // Origin header that is in allowed_origins), kpi.cleared/pending_*.

POST /federation/v1/leads                                            // WV-0220 typed contact
  Headers: X-Recca-Tenant-Key: rtk_live_…
  Body:    { recca_ref,             // HMAC token from landing ?recca_ref=
             external_id,           // your idempotency key, ≤ 200 chars
             lead_source?,          // free-form: "form_submit" / "phone_call" / ...
             contact?: {            // typed (passthrough — extra keys preserved)
               name?, email?,
               phone?,              // E.164 (+[1-9]\d{1,14}); 400 on bad format
               vk_id?, yandex_id?, telegram_id?,
               screen_name?, avatar_url?,
               recca_user_id?       // optional shortcut — creates a referral edge
             },
             metadata? }            // page_url, utm_*, ...
  → data: { lead_id, idempotent, referrer_user_id }
  Status: 201 on new, 200 on idempotent replay
  // Recca writes lead.metadata.referrer_snapshot = {name, avatar_url, screen_name}
  // captured from the referrer's user/profile — used by cabinet UI for fallback.

POST /federation/v1/conversions/register
  Headers: X-Recca-Tenant-Key: rtk_live_…
  Body:    { lead_id, amount_kopecks, currency: "RUB", external_order_id? }
  → data: { conversion_id, cascaded_bonuses: [...] }
```

### OAuth (browser popup)

```
GET /oauth/authorize?client_id=<slug>&response_type=code&scope=profile:read&state=<csrf>&popup=1
  Renders consent screen. On approval, postMessage's
  { type: "recca:auth-success", token, user_id, org_id, expires_at, state }
  to opener.origin.
```

## Setup steps

Run through these in order. Stop and ask the user only when truly
blocked (missing creds, ambiguous tech stack).

1. **Confirm tenant credentials.**
   Ask the user: "What is your tenant slug (e.g. `agp`, `shalash`) and
   tenant API key (`rtk_live_…`) from /org/<slug>/api-keys in your
   Recca cabinet?" The slug is required; the API key is required only
   for server-side federation calls (lead post, conversion register).

2. **Confirm allowed_origins on the Recca side.**
   Ask the user: "Are your site's HTTPS origins (e.g.
   `https://example.com`) added at /org/<slug>/integration → Allowed
   origins?" Without this, browser fetches to `/embed/v1/data/*` return
   403. Do NOT proceed past step 5 until confirmed.

3. **Install SDK packages.** Already done if `install.sh` ran. Otherwise:
   ```bash
   npm install \
     https://api.recca.ru/cdn/embed-components/recca-embed-components-0.1.2.tgz \
     https://api.recca.ru/cdn/packages/recca-federation-sdk-1.1.0.tgz
   ```
   The SDKs are NOT on public npm yet — install from CDN tarballs (npm
   handles HTTPS URLs natively, no auth needed). After install,
   `package.json` will have the URLs pinned in `dependencies`.

   Alternative for plain HTML / no-build setups: drop-in CDN script tag
   `<script type="module" src="https://api.recca.ru/cdn/embed-components/v1.js">`
   (no install needed, registers all `<recca-*>` Custom Elements globally).

4. **Add CDN script tag OR npm import** into the layout (Astro:
   `BaseLayout.astro`; Vue: `app.vue`; React: `_app.tsx`):

   ```html
   <script type="module" src="https://api.recca.ru/cdn/embed-components/v1.js"></script>
   ```

   OR (when bundling):

   ```ts
   import "@recca/embed-components"
   ```

5. **Wire OAuth login button** on the page where the partner logs in:

   ```html
   <recca-login-button
     tenant="<slug>"
     api-base="https://api.recca.ru"
     scope="profile:read">
   </recca-login-button>
   ```

   On click, opens the Recca OAuth popup; on success, the SDK stores
   the session JWT in `localStorage["recca:session"]` and emits
   `recca:session-ready`.

6. **Mint a referral link.** Two flows — pick ONE based on whether
   the tenant has its own auth (VKID / Yandex / email / Telegram).

   **Flow A — `<recca-referral-input>` (tenant WITHOUT own backend):**
   Used when the only login on the site is "Войти через Recca" itself.
   The widget opens an OAuth popup against Recca on click if no session
   exists, then POSTs to `/embed/v1/data/referral-link`.

   ```html
   <recca-referral-input
     tenant="<slug>"
     target="https://example.com/offer/sochi"
     target-type="website"
     theme="visitka"
     api-base="https://api.recca.ru">
   </recca-referral-input>
   ```

   **Flow B — `<recca-referral-display>` (tenant WITH own backend, RECOMMENDED for headless):**
   Your backend mints the link via `recca.referralLinks.create(tenantReccaId, {offer_id})`
   from `@recca/federation-sdk` (needs key with `users:impersonate` scope),
   returns the `short_url` to the page, and the widget renders
   input + copy + optional share buttons. **Zero network from the
   browser, no popup, no Recca-login UI.**

   ```ts
   // src/pages/api/referral/generate.ts (Astro)
   import { ReccaClient } from "@recca/federation-sdk"
   const recca = new ReccaClient({
     baseUrl: import.meta.env.RECCA_API_BASE,
     apiKey: import.meta.env.RECCA_TENANT_KEY,
   })
   export const POST = async ({ request, locals }) => {
     const { offer_id } = await request.json()
     const tenantReccaId = locals.user.tenant_recca_id   // from your session
     // SDK ≥ 1.2.0 returns { url, short_url, signed_ref, deeplink_id }.
     // url      = long landing URL with recca_ref baked in (~200 chars).
     //            Use when embedding on your own pages — no extra hop.
     // short_url = https://recca.ru/d/<12-char> (~30 chars). Use for
     //            chat shares, SMS, QR — visitor lands via Recca 302
     //            with a freshly-signed ref. Attribution is equivalent.
     const { url, short_url } = await recca.referralLinks.create(tenantReccaId, { offer_id })
     return new Response(JSON.stringify({ short_url, full_url: url }),
       { headers: { "content-type": "application/json" } })
   }
   ```

   ```html
   <!-- on the cabinet page -->
   <recca-referral-display
     id="rd"
     theme="visitka"
     share-text="Зацени тренинг по управлению">
   </recca-referral-display>
   <script>
     const r = await fetch("/api/referral/generate",
       { method: "POST", body: JSON.stringify({ offer_id: "off_..." }) })
     const { short_url } = await r.json()
     document.getElementById("rd").setAttribute("url", short_url)
   </script>
   ```

   The federation endpoint is idempotent: same `(tenantReccaId, offer_id)`
   → same short URL on repeat calls. Today it accepts only `offer_id`
   (not arbitrary `target_type: website`); create your courses/services
   as Offers in your Recca shop catalog.

7. **Add cabinet widgets** (optional but recommended). Resolve
   `<TENANT_RECCA_ID>` server-side via `/federation/v1/users/upsert`
   after VK/email auth, store it in the session, pass as `user-id`:

   ```html
   <recca-cabinet-kpi tenant="<slug>" user-id={tenantReccaId} theme="visitka"></recca-cabinet-kpi>
   <recca-bonus-balance tenant="<slug>" user-id={tenantReccaId}></recca-bonus-balance>
   <recca-recent-leads tenant="<slug>" user-id={tenantReccaId}></recca-recent-leads>
   <recca-referral-tree tenant="<slug>" user-id={tenantReccaId} max-depth="3"></recca-referral-tree>
   ```

8. **Backend: users upsert on partner registration.** When a new
   partner registers on the site (VK ID / Yandex ID / Telegram /
   email / SMS OTP), call `/federation/v1/users/upsert` and persist
   the returned `tenant_recca_id` next to your user record.

   **Critical:** pass `recca_ref` from the landing URL cookie if
   present — without it the new user won't be attributed to the
   referrer's tree. Pattern:

   ```js
   // 1. On landing page: capture ?recca_ref= into a 30-day cookie
   // 2. In your signup finaliser:
   await fetch("https://api.recca.ru/federation/v1/users/upsert", {
     method: "POST",
     headers: { "X-Recca-Tenant-Key": process.env.RECCA_TENANT_KEY,
                "Content-Type": "application/json" },
     body: JSON.stringify({
       vk_id: vkProfile?.id, yandex_id: yaProfile?.psuid,
       telegram_id: tgProfile?.id, phone: smsVerifiedPhone,
       email: formEmail, email_verified: !!emailVerifyToken,
       first_name, last_name,
       avatar_url: profile.photo_200 ?? profile.avatar,
       screen_name: profile.screen_name ?? profile.login,
       recca_ref: req.cookies.recca_ref,  // ← THE killer feature
     }),
   })
   ```

   Pass as many identity tokens as you have (`vk_id`, `yandex_id`,
   `telegram_id`, `phone`, `email + email_verified`) — Recca matches on
   priority order and persists the rest as future matching keys.

   **Parallel pattern (RECOMMENDED) — `users.upsert` + `leads.create`
   in the same handler.** `users.upsert` attaches the user to the
   referral tree (so they appear in `<recca-referral-tree>`), but does
   NOT create a lead row. To make the new signup appear in the
   referrer's `<recca-recent-leads>` and bump `kpi.leads_count`, also
   call `/federation/v1/leads` with the same `recca_ref` immediately
   after a successful upsert:

   ```js
   const { tenant_recca_id } = (await upsertResp.json()).data
   await db.users.update(localUserId, { recca_tenant_id: tenant_recca_id })

   // Idempotency: external_id must be deterministic per signup so retries
   // of the same auth-handler don't create duplicate leads.
   await fetch("https://api.recca.ru/federation/v1/leads", {
     method: "POST",
     headers: { "X-Recca-Tenant-Key": process.env.RECCA_TENANT_KEY,
                "Content-Type": "application/json" },
     body: JSON.stringify({
       recca_ref: req.cookies.recca_ref,         // same ref as upsert
       external_id: `signup:${localUserId}`,     // YOUR deterministic id
       lead_source: "form_submit",
       contact: { name: `${first_name} ${last_name ?? ""}`.trim(),
                  email: formEmail,
                  vk_id: vkProfile?.id, screen_name: profile.screen_name,
                  avatar_url: profile.photo_200 },
     }),
   })

   res.clearCookie("recca_ref")  // prevent next browser-tab signup attribution leak
   ```

   Without the parallel `leads.create`, the referrer sees an empty
   leads list in their cabinet despite the tree showing the new user.

9. **Backend: webhook receiver with HMAC verify.** Add a POST handler
   that verifies `X-Recca-Signature` against
   `HMAC-SHA256(webhook_secret, raw_body)` and dispatches on
   `event_type`. Webhook secret is shown ONCE during tenant
   provisioning (see `/org/<slug>/integration` → Webhook). The
   `@recca/federation-sdk` exports `webhookMiddleware()` for Express
   that does this verification for you.

10. **Run smoke checks** (curl, see Verification section). If JSON
    cabinet returns 200 + ACAO and webhook ping arrives, the
    integration is live.

## Code patterns

### Astro page (frontend)

```astro
---
// src/pages/cabinet.astro
const RECCA_TENANT = import.meta.env.PUBLIC_RECCA_TENANT
const RECCA_API_BASE = import.meta.env.PUBLIC_RECCA_API_BASE ?? "https://api.recca.ru"
---
<script type="module">
  import "@recca/embed-components"
</script>

<recca-login-button tenant={RECCA_TENANT} api-base={RECCA_API_BASE}></recca-login-button>

<recca-referral-input
  tenant={RECCA_TENANT}
  target={Astro.url.toString()}
  target-type="website"
  theme="visitka"
  api-base={RECCA_API_BASE}>
</recca-referral-input>
```

### Vue 3 SFC

```vue
<script setup lang="ts">
import { onMounted } from "vue"
onMounted(() => import("@recca/embed-components"))
const tenant = "agp"
const apiBase = "https://api.recca.ru"
</script>

<template>
  <recca-cabinet-kpi :tenant="tenant" :user-id="userId" :api-base="apiBase"></recca-cabinet-kpi>
</template>
```

(Add to `vite.config.ts`:
`vue({template: {compilerOptions: {isCustomElement: (tag) => tag.startsWith('recca-')}}})` —
prevents Vue from warning about unknown components.)

### Express webhook handler

```ts
import express from "express"
import crypto from "crypto"

const app = express()

app.post(
  "/api/recca/webhook",
  express.raw({ type: "application/json" }), // raw body needed for HMAC
  (req, res) => {
    const sig = req.header("X-Recca-Signature") || ""
    const expected = crypto
      .createHmac("sha256", process.env.RECCA_WEBHOOK_SECRET!)
      .update(req.body as Buffer)
      .digest("hex")
    if (sig !== expected) return res.status(401).end()

    const event = JSON.parse(req.body.toString("utf-8"))
    switch (event.event_type) {
      case "lead.created":       /* enqueue CRM sync */ ; break
      case "bonus.approved":     /* notify partner */   ; break
      case "conversion.cascaded": /* update finance */  ; break
    }
    res.json({ received: true })
  }
)
```

Or using the SDK helper:

```ts
import { ReccaFederationClient } from "@recca/federation-sdk"
const recca = new ReccaFederationClient({
  apiBase: "https://api.recca.ru",
  apiKey: process.env.RECCA_API_KEY!,
  webhookSecret: process.env.RECCA_WEBHOOK_SECRET!,
})
app.post("/api/recca/webhook",
  express.raw({ type: "application/json" }),
  recca.webhookMiddleware(),
  (req, res) => { /* req.reccaEvent is parsed + verified */; res.end() }
)
```

### Express: users upsert on VK callback

```ts
app.post("/api/auth/vk", async (req, res) => {
  // recca.users.upsert(...) returns { tenant_recca_id, is_new, claim_required }
  // claim_required is an INTERNAL flag — the bridged user does NOT need
  // to confirm anything; ignore it client-side.
  const { tenant_recca_id } = await recca.users.upsert({
    first_name: req.body.firstName,   // REQUIRED
    last_name: req.body.lastName,
    vk_id: String(req.body.vkId),
    email: req.body.email,            // either email OR vk_id required
    email_verified: false,
  })
  // persist tenant_recca_id on YOUR user row; return it to frontend
  res.json({ tenantReccaId: tenant_recca_id })
})
```

## No-code site integration (`/hooks/leads`)

For tenants WITHOUT a backend (Tilda / Creatium / any form builder).
Nothing to install — the owner creates a connection in the cabinet
(`/org/<slug>/integrations/website`), picks a provider, sets a
`form_offer_mapping` (form name → offer_id), and gets a **token** (shown
once). The form builder POSTs submissions to Recca:

```
POST https://api.recca.ru/hooks/leads
Auth: X-Recca-Token: <token>   (or Authorization: Bearer <token>, or ?token=)
```

- **Tilda** — Webhook URL `…/hooks/leads?token=…`; flat caps keys
  `Name/Phone/Email/Comment/Promo`; hidden field `recca=<deeplink-code>`
  for attribution; `formid` for the mapping.
- **Creatium** — Webhook on submit; nested `order.fields`; deeplink from
  `page.query.recca` (URL `?recca=`).
- **Custom** — `{ formName, contact:{name,phone,email,comment},
  metadata:{ promo_code, deeplink_code } }`.

Attribution priority: `form_offer_mapping[form_name]` →
`form_offer_mapping[promo]` → `recca` deeplink code (gives offer +
referrer) → shop-level lead. Idempotent by `(integration, payload)`. If
the user is configuring Tilda/Creatium, point them to this flow — do NOT
write SDK/backend code for it. Full guide:
<https://recca.ru/integration/guide#site>

> **CRITICAL — `/hooks/leads` leads do NOT reach the federation partner
> cabinet.** They are created with `organization_id = NULL` and appear
> only in the shop's admin "Лиды" tab. The embed partner cabinet
> (`/embed/v1/data/cabinet` → `kpi.leads_count`, recent-leads) filters
> `{ referrer_id, organization_id = <org> }`, so NULL-org leads never
> show there. If the tenant wants attribution INTO a partner's cabinet,
> use `POST /federation/v1/leads` (`X-Recca-Tenant-Key`) with the signed
> `recca_ref` — NOT `/hooks/leads`. `/hooks/leads` is for shop-form
> capture only.

## Verification

```bash
# 1. JSON cabinet probe — your origin must be whitelisted
curl -i "https://api.recca.ru/embed/v1/data/cabinet/<tenant_recca_id>?tenant=<slug>" \
  -H "Origin: https://<your-domain>"
# Expect:
#   HTTP/2 200
#   Access-Control-Allow-Origin: https://<your-domain>
#   {"user":{"display_name":"…"},"tenant":{…},"kpi":{…}}

# 2. Foreign-origin rejection (defense in depth)
curl -i "https://api.recca.ru/embed/v1/data/cabinet/<id>?tenant=<slug>" \
  -H "Origin: https://evil.example"
# Expect: HTTP/2 403, no ACAO header

# 3. Federation users.upsert
curl -X POST "https://api.recca.ru/federation/v1/users/upsert" \
  -H "X-Recca-Tenant-Key: rtk_live_…" \
  -H "Content-Type: application/json" \
  -d '{"first_name":"Test","email":"test@yoursite.com","email_verified":true}'
# Expect (201 if new, 200 if matched):
#   { "success": true, "data": { "tenant_recca_id": "DgqSi3...",
#     "is_new": true, "claim_required": true } }

# 4. Federation referral-link mint (headless, no popup)
curl -X POST "https://api.recca.ru/federation/v1/users/<tenant_recca_id>/referral-links" \
  -H "X-Recca-Tenant-Key: rtk_live_…" \
  -H "Content-Type: application/json" \
  -d '{"offer_id":"off_..."}'
# Expect (SDK 1.2.0+ / WV-0219):
#   { "success": true, "data": {
#       "url":        "https://yoursite.com/courses/x?recca_ref=eyJk...",
#       "short_url":  "https://recca.ru/d/x7b4fJhEs4tS",
#       "signed_ref": "eyJk...",
#       "deeplink_id":"dl_..." } }

# 4a. short_url redirect chain (verifies fresh-signed-ref re-issue per click)
curl -sIL "https://recca.ru/d/x7b4fJhEs4tS" | grep -i "^location:"
# Expect 2-3 hops:
#   recca.ru/d/<code>          → 301 → app.recca.ru/d/<code>  (nginx)
#   app.recca.ru/d/<code>      → 200 → meta refresh / external redirect
#   https://yoursite.com/courses/...?recca_ref=<FRESH_SIG>     (final)

# 5. Iframe widget (for tenants migrating from older iframe SDK)
curl -i "https://api.recca.ru/embed/v1/cabinet/<id>?tenant=<slug>&preview=1"
# Expect: text/html; CSP `frame-ancestors https://<your-domain>`
```

## Troubleshooting

| Symptom | Likely cause | Fix |
|---|---|---|
| `403` on JSON fetch from browser | Origin not in `allowed_origins` | Add to `/org/<slug>/integration → Разрешённые домены` |
| `401` on POST `/embed/v1/data/referral-link` | No OAuth session, or expired (TTL exceeded) | Click the `<recca-login-button>` again to re-auth; SDK auto-clears stale token on 401 |
| `<recca-*>` element shows as plain inline text | SDK not registered yet | Ensure `import "@recca/embed-components"` runs on the page (Vue/React: `onMounted`; Astro: `<script type="module">`) |
| Vue warns "Failed to resolve component: recca-xxx" | Vue compiler treats tags as components | Add `compilerOptions.isCustomElement: tag => tag.startsWith('recca-')` to vite/vue plugin config |
| Webhook signatures don't match | Wrong webhook secret OR raw body parsed as JSON before verify | Use `express.raw()` (NOT `express.json()`) on the webhook route; pass the raw `Buffer` to HMAC update |
| `users.upsert` returns 401 | Tenant API key missing or revoked | Re-issue at `/org/<slug>/api-keys`; update `RECCA_API_KEY` env var |
| `users.upsert` returns 400 `"first_name is required"` | Body uses obsolete `full_name` | Rename to `first_name` (+ optional `last_name`); `full_name` is NOT accepted |
| Tenant API call returns 403 `INSUFFICIENT_SCOPE` | Key lacks `users:impersonate` for referral-link mint | Re-issue key at `/org/<slug>/api-keys` with the Referral Links scope checked |
| Recca login popup opens when partner clicks "Получить ссылку" in authenticated cabinet | Using Flow A (`<recca-referral-input>`) while tenant has its own auth | Switch to Flow B: server-side mint via `recca.referralLinks.create(...)` + `<recca-referral-display>` (see Setup step 6, Flow B) |
| `short_url` is missing from `referralLinks.create()` response | SDK pinned to 1.1.0 (pre-WV-0219) | Bump `@recca/federation-sdk` to 1.2.0+ — `short_url` is added, no breaking changes |
| `https://recca.ru/d/<code>` returns 404 / wrong page after mint | Org has no `allowed_origins` or `landing_url_template` configured | Set both at `/org/<slug>/advanced` and on each Offer's landing template; verify with the curl `4a` step above |
| `result.tenant_recca_id` is `undefined` after `users.upsert` | Reading the raw response instead of `response.data.tenant_recca_id` | All federation responses are wrapped `{success, data, …}` — read `body.data.tenant_recca_id`. The `@recca/federation-sdk` client unwraps for you. |
| `URLSearchParams.get('recca_ref')` returns `null` on the landing page despite the link clearly carrying a token | The token is ~200+ chars (JWT-like header.payload.signature) AND the org's `landing_url_template` placed it as an UNNAMED param (`?<token>` instead of `?recca_ref=<token>`) | Set `landing_url_template` to use the named param at `/org/<slug>/advanced` — recommended shape is `https://{tenant_host}/your-path?recca_ref={ref}`. Since WV-0228 the cabinet form rejects bare `?{ref}` with an inline error; backend `renderLandingUrl` also throws `INVALID_TEMPLATE`. For legacy orgs with `?{ref}` template captured before WV-0228 — re-save the template with a named param. |
| Partner sees the new signup in `<recca-referral-tree>` but `kpi.leads_count = 0` and `<recca-recent-leads>` is empty | Called `users.upsert(recca_ref)` but didn't call `leads.create(recca_ref)` — they're independent | Call both endpoints in the same auth handler. See Setup step 8, "Parallel pattern". |
| Lead is created (200) via `POST /hooks/leads` but partner's `kpi.leads_count` stays 0 / recent-leads empty | `/hooks/leads` leads carry `organization_id = NULL` (shop-form capture) — the federation partner cabinet filters `organization_id = <org>`, so they never appear there | Use `POST /federation/v1/leads` (`X-Recca-Tenant-Key`) with the signed `recca_ref` instead. `recca_ref` is the long `?recca_ref=` token (NOT the `recca.ru/d/<code>` URL, NOT the bare deeplink code, NOT a `/hooks/leads` field). Verify without the 60s embed cache via `GET /federation/v1/users/<referrer_id>/leads`. |
| VKID popup mode fails on iPhone Safari (Safari ITP blocks the `postMessage` from `id.vk.ru`) | VKID SDK falls back to redirect mode → VK returns `GET /your-callback?code=…` but tenant only has a POST handler | Add a GET handler on the VKID callback URL that does the same thing as POST (read `?code=…` from query). NOT a Recca bug — VKID SDK behaviour. |
| Webhook delivery row exists in our DB with `failed` but tenant's nginx access log is empty | Possible DNS/SSL handshake failure or middleware blocking request | Use the "Прозвонить" button — UI now surfaces `http_code` or `network: <error>` directly. Check tenant nginx error.log (not just access) for TLS failures. |
| `claim_required: true` in `users.upsert` response | INTERNAL flag — does not require any tenant action | Ignore it. Persist `tenant_recca_id`; the bridged user is fully operational for federation. The flag is reserved for a future "claim native account" flow (WV-0156) that doesn't affect headless integration. |
| Bonus widgets show empty/zero | No bonus accounts exist for that user yet | Normal — bonuses appear after the partner's first attributed conversion |
| Referral link's `signed_ref` doesn't validate | `recommendation_text` or `promo_code` changed without re-issuing | Re-call `POST /embed/v1/data/referral-link` with same params; SDK is idempotent and returns existing link if any |

## Conventions you (the agent) must respect

- **Never** invent endpoints. The full inventory is above; use only those URLs.
- **Never** hardcode `rtk_live_…` keys in source files — read from env vars (`process.env.RECCA_API_KEY`, `import.meta.env.PUBLIC_RECCA_TENANT`).
- **Never** disable HMAC verification on webhooks ("for now") — partners that ship a verify-disabled handler hit production immediately.
- **Never** expose a tenant key (`rtk_live_…`) to the browser. Server-only — Flow B mint endpoints must be called from your backend, never from a `<script>` tag.
- **Default to Flow B (`<recca-referral-display>`)** whenever the tenant already has its own auth (VKID / Yandex / email / Telegram). Flow A (`<recca-referral-input>` with popup) is only for tenants whose ONLY login is Recca itself.
- **Default to visitka theme** unless the user explicitly asks for `light` or `dark`. Visitka is the AGP/Recca-canonical look.
- **Pin `api-base`** to `https://api.recca.ru` in production. Use `https://api-staging.recca.ru` only when the user says "staging".
- When the user has BOTH Astro + Express in one repo, wire the frontend (steps 4-7) AND backend (steps 8-9) without asking — they need both halves to work.
- After wiring is done, run the four curl checks from Verification and report results.

## More docs

- **Documentation center (all models + pitfalls, one page):** <https://recca.ru/integration/guide>
- Public landing: <https://recca.ru/integration>
- API reference: <https://recca.ru/integration/api-reference>
- Webhooks catalog: <https://recca.ru/integration/webhooks>
- SDK changelog: <https://recca.ru/integration/changelog.md>
