# API Reference - Ordio Loop Affiliate Partner System

**Last Updated:** 2026-03-25

Complete API reference for all affiliate partner system endpoints.

## Overview

The affiliate partner system provides RESTful API endpoints for partner management, authentication, data retrieval, and URL generation. All endpoints return JSON responses.

## Base URL

All API endpoints are located under `/v2/api/`

## Authentication

Most endpoints require authentication via PHP session. Authentication is handled by `affiliate-auth.php` include.

### Authentication Headers

- **Session-based:** Uses PHP `$_SESSION`
- **Cookie-based:** Remember me functionality uses cookies

## Endpoints

### Partner Registration

**Endpoint:** `POST /v2/api/partner-register.php`

**Description:** Register a new affiliate partner.

**Request Body:**

```json
{
  "name": "Partner Name",
  "email": "partner@example.com",
  "password": "SecurePassword123"
}
```

**Response (Success):**

```json
{
  "success": true,
  "message": "Partner registered successfully",
  "partner_id": "AP-20260129-ABC123",
  "verification_token": "abc123..."
}
```

**Response (Error):**

```json
{
  "success": false,
  "error": "Validation failed",
  "errors": ["Email is required", "Password must be at least 8 characters"]
}
```

**Status Codes:**

- `200` - Success
- `400` - Validation error
- `409` - Email already registered
- `500` - Server error

---

### Email Verification

**Endpoint:** `GET /v2/api/partner-verify-email.php`

**Description:** Verify partner email address.

**Query Parameters:**

- `token` (required) - Verification token
- `partner` (required) - Partner ID

**Response (Success):**

```html
<!DOCTYPE html>
<html>
  ...
</html>
```

**Response (Error):**

```html
<!DOCTYPE html>
<html>
  ...
</html>
```

**Status Codes:**

- `200` - Success or error (HTML response)

---

### Partner Login

**Endpoint:** `POST /v2/api/partner-login.php`

**Description:** Authenticate partner and create session.

**Request Body:**

```json
{
  "email": "partner@example.com",
  "password": "SecurePassword123",
  "remember_me": true
}
```

**Response (Success):**

```json
{
  "success": true,
  "message": "Login successful",
  "partner_id": "AP-20260129-ABC123",
  "redirect": "/v2/pages/partner-dashboard.php"
}
```

**Response (Error):**

```json
{
  "success": false,
  "error": "Invalid credentials",
  "remaining_attempts": 4
}
```

**Status Codes:**

- `200` - Success
- `401` - Invalid credentials
- `429` - Too many attempts (locked out)
- `500` - Server error

---

### Partner Logout

**Endpoint:** `GET /v2/api/partner-logout.php`

**Description:** Terminate partner session.

**Response:**

```json
{
  "success": true,
  "message": "Logged out successfully"
}
```

**Status Codes:**

- `200` - Success

---

### Password Reset Request

**Endpoint:** `POST /v2/api/partner-reset-password.php?action=request`

**Description:** Request password reset token.

**Request Body:**

```json
{
  "email": "partner@example.com"
}
```

**Response:**

```json
{
  "success": true,
  "message": "If an account exists with this email, a password reset link has been sent."
}
```

**Status Codes:**

- `200` - Success (always returns success for security)

---

### Password Reset Confirm

**Endpoint:** `POST /v2/api/partner-reset-password.php?action=confirm`

**Description:** Confirm password reset with token.

**Request Body:**

```json
{
  "token": "reset_token_here",
  "partner": "AP-20260129-ABC123",
  "password": "NewPassword123"
}
```

**Response (Success):**

```json
{
  "success": true,
  "message": "Password has been reset successfully."
}
```

**Response (Error):**

```json
{
  "success": false,
  "error": "Invalid or expired reset link"
}
```

**Status Codes:**

- `200` - Success
- `400` - Invalid token or expired

---

### Update Profile

**Endpoint:** `POST /v2/api/partner-update-profile.php`

**Description:** Update partner profile information.

**Authentication:** Required

**Request Body:**

```json
{
  "name": "Updated Name"
}
```

**Response (Success):**

```json
{
  "success": true,
  "message": "Profile updated successfully"
}
```

**Status Codes:**

- `200` - Success
- `401` - Unauthorized
- `400` - Validation error

---

### Change Password

**Endpoint:** `POST /v2/api/partner-change-password.php`

**Description:** Change partner password.

**Authentication:** Required

**Request Body:**

```json
{
  "current_password": "OldPassword123",
  "new_password": "NewPassword123",
  "confirm_password": "NewPassword123"
}
```

**Response (Success):**

```json
{
  "success": true,
  "message": "Password changed successfully"
}
```

**Response (Error):**

```json
{
  "success": false,
  "error": "Current password is incorrect"
}
```

**Status Codes:**

- `200` - Success
- `401` - Unauthorized
- `400` - Validation error

---

### Dashboard Data

**Endpoint:** `GET /v2/api/affiliate-dashboard-data.php`

**Description:** Get dashboard data for authenticated partner.

**Authentication:** Required

**Response:**

```json
{
  "success": true,
  "data": {
    "kpis": {
      "total_leads": 25,
      "total_deals": 5,
      "total_mrr": 45.5,
      "active_deals": 3
    },
    "mrr_trend": [
      { "month": "2026-01", "mrr": 30.0 },
      { "month": "2026-02", "mrr": 45.5 }
    ],
    "recent_activity": [
      {
        "type": "lead",
        "name": "John Doe",
        "date": "2026-01-29",
        "status": "new"
      }
    ]
  }
}
```

**Status Codes:**

- `200` - Success
- `401` - Unauthorized

---

### Leads Data

**Endpoint:** `GET /v2/api/affiliate-leads.php`

**Description:** Get leads data for authenticated partner.

**Authentication:** Required

**Query Parameters:**

- `status` (optional) - Filter by status (new, contacted, qualified, converted, lost)

**Response:**

```json
{
  "success": true,
  "data": {
    "leads": [
      {
        "id": "contact_123",
        "name": "John Doe",
        "email": "john@example.com",
        "status": "new",
        "referral_date": "2026-01-29T10:00:00Z",
        "source": "email"
      }
    ],
    "total": 25,
    "filtered": 10
  }
}
```

**Status Codes:**

- `200` - Success
- `401` - Unauthorized

---

### Earnings Data

**Endpoint:** `GET /v2/api/affiliate-earnings.php`

**Description:** Get earnings data for authenticated partner. MRR and deal formatting use **effective level** (`getEffectivePartnerLevel()`), not raw partner level. Partners with `level_override` get correct MRR share based on their manual level.

**Authentication:** Required

**Response:**

```json
{
  "success": true,
  "data": {
    "mrr": { "total_mrr": 100.0, "active_mrr": 75.0, "active_deals": 3 },
    "mrr_status_breakdown": { "active": { "mrr": 75.0, "percentage": 80 }, "paused": { "mrr": 15.0, "percentage": 16 }, "cancelled": { "mrr": 10.0, "percentage": 4 } },
    "monthly_mrr": { "2026-01": { "mrr": 50.0 }, "2026-02": { "mrr": 75.0 } },
    "deals": [ { "deal_id": "deal_123", "deal_name": "Acme Corp", "mrr": 50.0, "partner_mrr": 7.5, "status": "active" } ],
    "top_deals": [],
    "deal_status_distribution": {},
    "total_deals": 1,
    "last_sync": "2026-02-11T02:00:00+00:00"
  }
}
```

**Effective Level:** MRR share percentage and per-deal partner MRR are calculated using `getEffectivePartnerLevel()` (respects `level_override` when set by admin). Do not use `$partner['level']` directly.

**Status Codes:**

- `200` - Success
- `401` - Unauthorized

---

### Leaderboard Data

**Endpoint:** `GET /v2/api/affiliate-leaderboard.php`

**Description:** Get leaderboard data (top partners by MRR). Returns the current partner's rank and MRR for "Dein Rang" display.

**Authentication:** Required

**Response:**

```json
{
  "success": true,
  "data": {
    "leaderboard": [
      {
        "partner_id": "AP-20260101-XXX001",
        "name": null,
        "mrr": 500.0,
        "active_deals": 5
      },
      {
        "partner_id": "AP-20260115-XXX002",
        "name": null,
        "mrr": 350.0,
        "active_deals": 3
      }
    ],
    "partner_rank": 2,
    "partner_mrr": 350.0,
    "last_sync": "2026-02-11T02:00:00+00:00"
  }
}
```

**Response Fields:**

| Field | Type | Description |
|-------|------|-------------|
| `leaderboard` | array | Top N partners (limit from `AFFILIATE_LEADERBOARD_SIZE`), sorted by MRR descending. Each entry has `partner_id`, `name` (null when anonymous), `mrr`, `active_deals`. |
| `partner_rank` | int\|null | Current partner's rank (1-based). Null if partner has no MRR or not in mrr_summary. |
| `partner_mrr` | float\|null | Current partner's total MRR. Null if not found. |
| `last_sync` | string\|null | Last HubSpot sync timestamp (ISO 8601). |

**Status Codes:**

- `200` - Success
- `401` - Unauthorized

---

### Sitemap Pages (Referral Table)

**Endpoint:** `GET /v2/api/affiliate-sitemap-pages.php`

**Description:** Returns all promotable pages from the main sitemap for the "Seite wählen" table. Used by the referral URLs page to display searchable, filterable, paginated pages.

**Authentication:** Required

**Request:** GET, no body.

**Response (Success):**

```json
{
  "success": true,
  "pages": [
    {
      "path": "/schichtplan",
      "label": "Schichtplanung",
      "category": "product"
    },
    {
      "path": "/alternativen/planday-vergleich",
      "label": "Planday Vergleich",
      "category": "comparison"
    }
  ],
  "categories": [
    { "id": "homepage", "label": "Startseite" },
    { "id": "product", "label": "Produkt" },
    { "id": "tools", "label": "Rechner & Tools" }
  ]
}
```

**Response (Error):**

```json
{
  "success": false,
  "error": "Sitemap konnte nicht geladen werden."
}
```

**Status Codes:**

- `200` - Success or error (check `success` in body)
- `401` - Unauthorized

---

### Generate Referral URL

**Endpoint:** `POST /v2/api/affiliate-generate-url.php`

**Description:** Generate custom referral URL with optional target path and UTM parameters. Any path that appears in the main site URL list (`v2/data/sitemap-pages.json`, same as /sitemap.xml) is allowed.

**Authentication:** Required

**Request Body:**

| Field          | Type   | Required | Description                                                                                                           |
| -------------- | ------ | -------- | --------------------------------------------------------------------------------------------------------------------- |
| `path`         | string | No       | Target path (e.g. `/`, `/schichtplan`, `/preise`). Must be a path from the main sitemap. Omitted or empty = homepage. |
| `utm_source`   | string | No       | UTM source (e.g. newsletter, partner).                                                                                |
| `utm_medium`   | string | No       | UTM medium (e.g. email, social, referral).                                                                            |
| `utm_campaign` | string | No       | UTM campaign name.                                                                                                    |
| `utm_content`  | string | No       | UTM content (differentiate links within a campaign).                                                                  |
| `utm_term`     | string | No       | UTM term (keyword or audience).                                                                                       |

Example:

```json
{
  "path": "/preise",
  "utm_source": "email",
  "utm_medium": "newsletter",
  "utm_campaign": "january_promo",
  "utm_content": "banner",
  "utm_term": "workforce_management"
}
```

**Response:**

```json
{
  "success": true,
  "url": "https://www.ordio.com/preise?affiliate=AP-20260129-ABC123&utm_source=email&utm_medium=newsletter&utm_campaign=january_promo&utm_content=banner&utm_term=workforce_management",
  "partner_id": "AP-20260129-ABC123"
}
```

**Validation:**

- If `path` is provided and not in the sitemap-derived promotable list (from v2/data/sitemap-pages.json and sitemap-blog.xml), the API returns `400` with error message (e.g. "Ungültiger Pfad. Nur erlaubte Seiten können verlinkt werden.").

**Status Codes:**

- `200` - Success
- `400` - Bad Request (e.g. invalid path)
- `401` - Unauthorized

---

### Admin Program Metrics

**Endpoint:** `GET /v2/api/affiliate-admin-program-metrics.php`

**Description:** Program-wide KPIs, trends, UTM aggregates, funnel snapshot, and activation counts. **Admin only.** Reads `loadPartnerData()` + `loadCachedHubSpotData()` only (no live HubSpot).

**Query parameters:**

| Parameter | Description |
|-----------|-------------|
| `from` | Optional. `YYYY-MM-DD` (UTC start of day). Default: rolling 90 days before `to`. |
| `to` | Optional. `YYYY-MM-DD` (UTC end of day). Default: now. |
| `grain` | Optional. `day`, `week`, or `month` — bucket size for **trend series only** (default `month`). Long ranges may be coerced server-side; see `meta.trend_granularity_*` and `trends.granularity_*`. |

Maximum span: **24 months** (range may be clamped; response `range.clamped`).

**Authentication:** Required. **Authorization:** Admin (`isAffiliateAdmin()`).

**Response:** JSON `success` + `data` with `range`, `meta` (inkl. `hubspot`, `trend_granularity_requested` / `effective` / optional `note`), `kpis`, `funnel_snapshot` (exklusive Status-Buckets + `stage_sum` / `rates`), `trends` (`period_keys`, `leads_per_period`, …; legacy aliases `months`, `leads_per_month`, …), `distribution`, `top_partners`, `activation_engagement`, `utm`, `period_leads`, `period_deals`. See [PROGRAM_ANALYTICS_GUIDE.md](./PROGRAM_ANALYTICS_GUIDE.md).

**Consistency check:** `php v2/scripts/affiliate/validate-program-analytics-consistency.php`

**Status codes:**

- `200` – Success
- `401` – Not authenticated
- `403` – Forbidden (not admin)

---

### Admin List (Partners)

**Endpoint:** `GET /v2/api/affiliate-admin-list.php`

**Description:** List all partners with stats (leads, deals, MRR). Admin only.

**Authentication:** Required (affiliate session). **Authorization:** Admin role (`isAffiliateAdmin()`).

**Response (Success):**

```json
{
  "success": true,
  "data": {
    "last_sync": "2026-04-08T11:23:26+00:00",
    "partners": [
      {
        "partner_id": "AP-20260129-ABC123",
        "name": "Partner Name",
        "email": "partner@example.com",
        "status": "active",
        "level": "starter",
        "effective_level": "starter",
        "level_override": false,
        "level_override_until": null,
        "registration_date": "2026-01-29T10:00:00Z",
        "last_login_at": "2026-02-01T14:00:00+00:00",
        "last_active_at": "2026-02-01T14:05:00+00:00",
        "is_admin": false,
        "is_config_admin": false,
        "leads_count": 10,
        "deals_count": 3,
        "deals_in_period": 2,
        "total_mrr": 45.5
      }
    ]
  }
}
```

| Field | Type | Description |
|-------|------|-------------|
| `last_sync` | string\|null | Timestamp of the last successful HubSpot affiliate sync (`affiliate_hubspot_cache.json`), ISO 8601. Null if missing. Shown on Verwaltung next to „Sync mit HubSpot“. |

**Status Codes:**

- `200` - Success
- `403` - Forbidden (not admin)
- `401` - Unauthorized

---

### Admin Update Partner

**Endpoint:** `PATCH /v2/api/affiliate-admin-update-partner.php`

**Description:** Update partner status, admin flag, or level override. Admin only. Bootstrap admin (config) cannot be deactivated or unadmined. Uses `affiliate-api-base.php` and `requireAffiliateAuthAPI()` for consistent session/auth. Unauthenticated requests get **401 JSON** (no redirect) when the request is an API request (request URI contains `/v2/api/`, or `Accept: application/json`, or `X-Requested-With: XMLHttpRequest`). **403** is returned when the authenticated user is not an admin. For 403 troubleshooting (same-origin, re-login, logs), see [TROUBLESHOOTING.md](TROUBLESHOOTING.md#troubleshooting-403-when-assigning-admin).

**Effective status when setting `status` to `active`:** To avoid bypassing email verification, when the client sends `status: "active"` (reactivate), the server may set the stored status to **pending_verification** if the partner has no `email_verified_at`. In that case the partner must still verify their email before they can log in. If the partner has `email_verified_at`, or the request includes `force_active: true`, the stored status is set to **active**. The audit log records the effective status applied. See [TROUBLESHOOTING.md](TROUBLESHOOTING.md#partner-shows-pending-after-i-reactivated).

**Authentication:** Required. **Authorization:** Admin role.

**Request Body:**

| Field         | Type    | Description                                                                 |
| ------------- | ------- | --------------------------------------------------------------------------- |
| `partner_id`  | string  | Partner ID (required)                                                       |
| `status`      | string  | Optional: `active`, `deactivated`                                           |
| `is_admin`    | boolean | Optional: assign or revoke admin                                           |
| `force_active`| boolean | Optional: when setting `status` to `active`, set stored status to active even if partner has no `email_verified_at` (support override; logged in audit) |
| `level`       | string  | Optional: `beginner`, `starter`, `partner`, `pro` (required when `level_override` is true) |
| `level_override` | boolean | Optional: `true` to set manual level, `false` to revert to automatic calculation |
| `level_override_until` | string | Optional: ISO date (YYYY-MM-DD). Must be future date. Time-limited override; when past, sync auto-clears override |

**Response (Success):**

```json
{
  "success": true
}
```

**Status Codes:**

- `200` - Success
- `400` - Bad Request (e.g. bootstrap admin protection)
- `403` - Forbidden (authenticated but not admin). Response body: `{ "success": false, "error": "Keine Admin-Rechte", "hint": "Log out and log in again to refresh session; or ensure your email is in AFFILIATE_ADMIN_EMAILS or your partner record has is_admin." }`. The optional `hint` field suggests next steps; the Admin page shows it in the error modal when present.
- `401` - Unauthorized (e.g. `error`: "Not authenticated")

---

### Admin Audit Log

**Endpoint:** `GET /v2/api/affiliate-admin-audit.php`

**Description:** Get recent admin actions (who deactivated/activated whom, who assigned/revoked admin). Admin only.

**Authentication:** Required. **Authorization:** Admin role.

**Query Parameters:**

- `limit` (optional) - Max entries (default: 50)

**Response (Success):**

```json
{
  "success": true,
  "data": {
    "entries": [
      {
        "timestamp": "2026-02-01T14:00:00+00:00",
        "actor_email": "admin@example.com",
        "target_partner_id": "AP-20260129-ABC123",
        "target_email": "partner@example.com",
        "action": "status_change",
        "details": { "status": "deactivated" }
      },
      {
        "timestamp": "2026-02-01T14:05:00+00:00",
        "actor_email": "admin@example.com",
        "target_partner_id": "AP-20260129-ABC123",
        "target_email": "partner@example.com",
        "action": "level_change",
        "details": { "level": "pro", "level_override_until": "2026-08-01" }
      },
      {
        "timestamp": "2026-02-01T14:10:00+00:00",
        "actor_email": "admin@example.com",
        "target_partner_id": "AP-20260129-ABC123",
        "target_email": "partner@example.com",
        "action": "level_revert",
        "details": {}
      }
    ]
  }
}
```

**Status Codes:**

- `200` - Success
- `403` - Forbidden
- `401` - Unauthorized

---

## Error Responses

All endpoints follow a consistent error response format:

```json
{
  "success": false,
  "error": "Error message",
  "errors": ["Error detail 1", "Error detail 2"]
}
```

**Admin 403 responses:** Admin-only endpoints (e.g. `affiliate-admin-update-partner.php`) return 403 when the authenticated user is not an admin. The response may include an optional `hint` field with next steps (e.g. re-login, set `is_admin` on partner record). The Admin page displays this hint in the error modal when present.

## Status Codes

- `200` - Success
- `400` - Bad Request (validation error)
- `401` - Unauthorized (authentication required)
- `405` - Method Not Allowed
- `409` - Conflict (e.g., email already exists)
- `429` - Too Many Requests (rate limited)
- `500` - Internal Server Error

## Rate Limiting

### Login Attempts

- **Maximum Attempts:** 5
- **Lockout Duration:** 15 minutes
- **Reset:** Automatic after lockout expires

### API Rate Limits

- **General Endpoints:** No specific limits (session-based)
- **HubSpot API:** Subject to HubSpot rate limits

## Authentication Flow

1. Partner submits login credentials
2. API validates credentials
3. Session created with partner ID
4. Subsequent requests use session for authentication
5. Session expires after 30 minutes inactivity

## Data Formats

### Dates

All dates are in ISO 8601 format: `YYYY-MM-DDTHH:mm:ssZ`

Example: `2026-01-29T10:00:00Z`

### Partner IDs

Format: `AP-YYYYMMDD-XXXXXX`

- `AP` - Affiliate Partner prefix
- `YYYYMMDD` - Registration date
- `XXXXXX` - Random 6-character hex string

Example: `AP-20260129-ABC123`

### MRR Values

- Format: Decimal number (2 decimal places)
- Currency: EUR (€)
- Example: `45.50`

## Testing

### Test Endpoints

Use the test scripts in `tests/affiliate/`:

- `partner-registration-test.php` - Registration tests
- `hubspot-sync-test.php` - Sync tests
- `mrr-calculation-test.php` - MRR calculation tests

### Manual Testing

1. Use browser developer tools (Network tab)
2. Use curl or Postman for API testing
3. Check response status codes and JSON structure
4. Verify authentication requirements

## Related Documentation

- **[Partner Guide](PARTNER_GUIDE.md)** - User guide for partners
- **[Dashboard Guide](DASHBOARD_GUIDE.md)** - Dashboard usage guide
- **[Architecture](ARCHITECTURE.md)** - Technical architecture
