> For the complete documentation index, see [llms.txt](https://docs.replycorp.io/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.replycorp.io/api-documentation.md).

# API Documentation

{% hint style="info" %}
Want the fastest “how much work is this?” checklist? Start with the [Integration Guide](/integration-guide.md).
{% endhint %}

## Introduction

Welcome to the ReplyCorp API documentation. This guide will help you integrate conversion tracking into your application.

Looking for programmatic reads — campaign stats, per-post analytics, conversion exports — or want to connect an AI agent? See [Reporting API & MCP (Growth+)](/reporting-api-and-mcp.md). Conversion posting and reconciliation documented on this page are available on every tier.

### Table of Contents

1. [Getting Started](#getting-started)
2. [Authentication](#authentication)
3. [Conversion Tracking](#conversion-tracking)
4. [Campaign Points Configuration](#campaign-points-configuration)
5. [Error Handling](#error-handling)
6. [Rate Limits](#rate-limits)

***

### Getting Started

#### API Version

**Current Version**: v2.3.0

#### Base URL

All API requests should be made to:

```
https://api.replycorp.io/api/v1
```

#### Prerequisites

Before you can use the ReplyCorp API, you need to:

1. Register an organization account
2. Obtain your API key
3. Create a campaign

***

### Authentication

#### Step 1: Request Access

**Important:** Organization registration requires email whitelisting. Before you can register, your email address must be approved by an administrator.

To request access, please contact support at <support@replycorp.io> with:

* Your organization name
* Your email address (this will be used as the admin email)
* Brief description of your use case

Once your email is whitelisted, you can proceed with registration.

#### Step 2: Register Your Organization

After your email has been whitelisted, register your organization and receive an API key.

**Endpoint:** `POST /api/v1/auth/register`

**Request Body:**

```json
{
  "organizationName": "Your Company Name",
  "organizationSlug": "your-company-slug",
  "adminEmail": "admin@yourcompany.com",
  "adminPassword": "your-secure-password"
}
```

**Example Request:**

```bash
curl -X POST https://api.replycorp.io/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{
    "organizationName": "ReplyCorp",
    "organizationSlug": "replycorp",
    "adminEmail": "admin@replycorp.io",
    "adminPassword": "SecurePassword123!"
  }'
```

**Error Response (403 Forbidden) - Email Not Whitelisted:**

```json
{
  "error": "Email not whitelisted. Organization registration requires approval. Please contact support to request access."
}
```

**Success Response:**

```json
{
  "message": "Organization registered successfully",
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "organization": {
    "id": "org_123456",
    "name": "ReplyCorp",
    "slug": "replycorp",
    "plan": "FREE",
    "apiKey": "rweb_basic_replycorp_tGF9jIsFpQJPkcUUwQqkaYqG"
  },
  "user": {
    "id": "user_123456",
    "email": "admin@replycorp.io",
    "role": "OWNER"
  }
}
```

**Important:** Save your `apiKey` securely. You'll need it for all API requests.

**Note:** If you receive a 403 error indicating your email is not whitelisted, please contact support to request access before attempting registration again.

#### Step 3: Login (Alternative Method)

If you already have an account, you can log in to retrieve your API key.

**Endpoint:** `POST /api/v1/auth/login`

**Request Body:**

```json
{
  "email": "admin@yourcompany.com",
  "password": "your-password"
}
```

**Example Request:**

```bash
curl -X POST https://api.replycorp.io/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{
    "email": "admin@replycorp.io",
    "password": "SecurePassword123!"
  }'
```

**Response:**

```json
{
  "message": "Login successful",
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "organization": {
    "id": "org_123456",
    "name": "ReplyCorp",
    "slug": "replycorp",
    "plan": "FREE"
  },
  "user": {
    "id": "user_123456",
    "email": "admin@replycorp.io",
    "role": "OWNER"
  }
}
```

**Note:** To retrieve your API key after login, you'll need to make an authenticated request to get your organization details.

#### Step 4: Using Your API Key

Once you have your API key, include it in all API requests using the `X-API-Key` header:

```bash
curl -H "X-API-Key: rweb_basic_replycorp_tGF9jIsFpQJPkcUUwQqkaYqG" \
  https://api.replycorp.io/api/v1/...
```

**API Key Format:**

* Format: `rweb_{plan-tier}_{organization-slug}_{random-string}`
* Plan tiers: `basic`, `standard`, `professional`, `enterprise`
* Examples:
  * `rweb_basic_replycorp_tGF9jIsFpQJPkcUUwQqkaYqG` (Basic plan)
  * `rweb_standard_replycorp_tGF9jIsFpQJPkcUUwQqkaYqG` (Standard plan)
  * `rweb_professional_replycorp_tGF9jIsFpQJPkcUUwQqkaYqG` (Professional plan)
  * `rweb_enterprise_replycorp_tGF9jIsFpQJPkcUUwQqkaYqG` (Enterprise plan)
* The tier prefix indicates your organization's subscription plan level

***

### Conversion Tracking

#### Overview

The conversion tracking API allows you to record purchases or other conversion events. When a conversion is recorded, ReplyCorp automatically:

1. Identifies the buyer's Twitter/X account
2. Calculates attribution to influencers who drove the conversion
3. Distributes points to attributed influencers (positive for positive events, negative for negative events)

#### Event Types

The API supports multiple event types to track both positive and negative events:

**Positive Events** (require positive `amount`):

* `lock` - REPPO tokens locked
* `buy` - Tokens purchased
* `purchase` - Purchase made (default if `eventType` is omitted)

**Zero-Amount Events** (`amount` is optional, defaults to `0`):

* `signup` - Waitlist or registration signup. No commission required, influence points are not generated. Attribution is tracked normally.

**Negative Events** (require negative `amount`):

* `unlock` - REPPO tokens unlocked
* `sell` - Tokens sold
* `refund` - Purchase refunded

**Net Volume Calculation:**

* Net volume is calculated by summing all conversion values (locks minus unlocks, purchases minus refunds)
* Attribution points are distributed as positive points for positive events and negative points for negative events
* This maintains accurate net attribution tracking

#### Post a Conversion

**Endpoint:** `POST /api/v1/campaigns/{campaignId}/conversions`

**Authentication:** Required (API Key)

> **Using both Conversion Intelligence and Rewards?** Current setups run both in a **single organization** (one API key, one campaign ID) — Rewards is enabled as an add-on, and the only API difference is sending `commission` on eligible conversions once Rewards is activated. Only legacy customers who were explicitly set up with two separate organizations need dual reporting; see **Integration Guide → Legacy: two-organization dual reporting**.

**Request Headers:**

```
X-API-Key: rweb_basic_replycorp_tGF9jIsFpQJPkcUUwQqkaYqG
Content-Type: application/json
```

**Request Body:**

```json
{
  "twitterId": "1234567890",
  "eventType": "lock",
  "amount": 123.45,
  "commission": 24.69,
  "transactionHash": "0xdef456...",
  "walletAddress": "0xabc123...",
  "metadata": {
    "txHash": "0xdef456...",
    "expiresAt": 1735689600,
    "userId": "privy_123",
    "type": "initial_balance",
    "source": "admin_credit"
  }
}
```

**Field Descriptions:**

| Field                  | Type   | Required                  | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| ---------------------- | ------ | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `twitterId`            | string | Yes\*                     | The X (Twitter) user ID of the person who made the purchase/lock/unlock. \*Either `twitterId` or `verificationCode` must be provided — use `verificationCode` for Instagram-verified buyers.                                                                                                                                                                                                                                                                                   |
| `verificationCode`     | string | Yes\*                     | Instagram buyers: the unique discount code issued to the buyer through ReplyCorp's Instagram DM verification (e.g. `YOURBRAND-4F9A2C`). The code carries the buyer's verified Instagram identity, so no `twitterId` is needed. Codes are campaign-scoped and single-use: the first attributed conversion marks the code redeemed. If both fields are sent, `twitterId` takes precedence. See [Identifying Instagram Buyers](#identifying-instagram-buyers-verification-codes). |
| `eventType`            | string | No                        | Event type: `lock`, `unlock`, `buy`, `sell`, `purchase`, `refund`, `signup`. Defaults to `purchase` if omitted (backward compatibility)                                                                                                                                                                                                                                                                                                                                        |
| `amount`               | number | Yes\*                     | The conversion/sale amount (revenue). Used for attribution calculation (who influenced the sale). Must be positive for `lock`/`buy`/`purchase`, negative for `unlock`/`sell`/`refund`, non-negative for `signup`. \*Optional for `signup` events (defaults to `0`).                                                                                                                                                                                                            |
| `commission`           | number | Yes\*                     | The commission amount the brand is paying on this conversion. Used to calculate influence points. \*Required when an attributed conversion has influence points enabled (not required for `signup` events). Must be non-negative and cannot exceed `amount`. Send `0` to retain the conversion and attribution record without generating billing, fees, or reward batches.                                                                                                     |
| `currency`             | string | No                        | Currency the monetary fields (`amount`, `commission`, `totalVolume`, `netProfit`) are denominated in. Defaults to `USD`. Supported token codes (e.g. `APE`) are listed at `GET /api/v1/conversions/supported-currencies`. Non-USD amounts are converted to USD at ingest using a [DIA](https://www.diadata.org) spot quote; the applied rate is stored on the conversion and returned in the response `fx` object. Points, billing, and payments remain USD-denominated.       |
| `transactionHash`      | string | No                        | Stable chain transaction identifier, strongly recommended for chain conversions. It is unique within the campaign and makes retries idempotent. EVM hashes are normalized to lowercase.                                                                                                                                                                                                                                                                                        |
| `walletAddress`        | string | No                        | Optional wallet address (EVM or Solana) associated with the buyer                                                                                                                                                                                                                                                                                                                                                                                                              |
| `metadata`             | object | No                        | Optional metadata object for storing additional conversion data. Will be merged with server-generated metadata (`eventType`, `walletAddress`). Server fields take precedence if conflicts occur                                                                                                                                                                                                                                                                                |
| `metadata.txHash`      | string | Yes (for `lock`/`unlock`) | Unique transaction hash identifying the lock. Required for lock and unlock events to enable expiry tracking and prevent duplicate unlocks                                                                                                                                                                                                                                                                                                                                      |
| `metadata.expiresAt`   | number | No (for `lock`)           | Expiry timestamp in epoch seconds. When provided for lock events, the system will automatically create an unlock event at expiry time if the lock hasn't been manually unlocked                                                                                                                                                                                                                                                                                                |
| `metadata.userId`      | string | No                        | Optional user identifier (e.g., Privy user ID)                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `originalConversionId` | string | No                        | For `refund` events: the ID of the original conversion being refunded. When provided, any pending or active influence point batches for the original conversion are cancelled.                                                                                                                                                                                                                                                                                                 |

**Example Requests:**

**Positive Event (Lock with Expiry):**

```bash
curl -X POST https://api.replycorp.io/api/v1/campaigns/YOUR_CAMPAIGN_ID/conversions \
  -H "X-API-Key: rweb_basic_replycorp_tGF9jIsFpQJPkcUUwQqkaYqG" \
  -H "Content-Type: application/json" \
  -d '{
    "twitterId": "1234567890",
    "eventType": "lock",
    "amount": 123.45,
    "commission": 24.69,
    "walletAddress": "0xabc123def456ghi789jkl012mno345pqr678stu901vwx234yz",
    "metadata": {
      "txHash": "0xdef4567890123456789012345678901234567890123456789012345678901234",
      "expiresAt": 1735689600,
      "userId": "privy_123"
    }
  }'
```

**Negative Event (Manual Unlock):**

```bash
curl -X POST https://api.replycorp.io/api/v1/campaigns/YOUR_CAMPAIGN_ID/conversions \
  -H "X-API-Key: rweb_basic_replycorp_tGF9jIsFpQJPkcUUwQqkaYqG" \
  -H "Content-Type: application/json" \
  -d '{
    "twitterId": "1234567890",
    "eventType": "unlock",
    "amount": -50.00,
    "walletAddress": "0xabc123def456ghi789jkl012mno345pqr678stu901vwx234yz",
    "metadata": {
      "txHash": "0xdef4567890123456789012345678901234567890123456789012345678901234"
    }
  }'
```

**Standard Purchase (with commission for influence points):**

```bash
curl -X POST https://api.replycorp.io/api/v1/campaigns/YOUR_CAMPAIGN_ID/conversions \
  -H "X-API-Key: rweb_basic_replycorp_tGF9jIsFpQJPkcUUwQqkaYqG" \
  -H "Content-Type: application/json" \
  -d '{
    "twitterId": "1234567890",
    "amount": 100.00,
    "commission": 2.00,
    "transactionHash": "0xdef4567890123456789012345678901234567890123456789012345678901234",
    "walletAddress": "0xabc123def456ghi789jkl012mno345pqr678stu901vwx234yz"
  }'
```

**Instagram Buyer (identified by verification code instead of twitterId):**

```bash
curl -X POST https://api.replycorp.io/api/v1/campaigns/YOUR_CAMPAIGN_ID/conversions \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "verificationCode": "YOURBRAND-4F9A2C",
    "eventType": "purchase",
    "amount": 100.00,
    "commission": 2.00
  }'
```

The code resolves to the buyer's DM-verified Instagram identity and the conversion attributes against their Instagram engagement history. See [Identifying Instagram Buyers](#identifying-instagram-buyers-verification-codes) for how buyers obtain codes and how to resolve them ahead of time.

**Purchase Recorded Without Billing or Rewards:**

```bash
curl -X POST https://api.replycorp.io/api/v1/campaigns/YOUR_CAMPAIGN_ID/conversions \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "twitterId": "1234567890",
    "eventType": "purchase",
    "amount": 100.00,
    "commission": 0,
    "transactionHash": "0xdef4567890123456789012345678901234567890123456789012345678901234",
    "walletAddress": "0xabc123def456ghi789jkl012mno345pqr678stu901vwx234yz",
    "metadata": {
      "rewardExclusionReason": "existing-partner-commission"
    }
  }'
```

The zero-commission request still records the purchase and attribution analytics. It does not create a brand charge, ReplyCorp fee, buyer reward, poster reward, referral reward, or fee-router transaction.

### Transaction idempotency

When `transactionHash` is supplied, `(campaignId, transactionHash)` identifies one conversion. A retry with the same X user, event type, amount, and commission returns HTTP `200`, the original `conversion.id`, and `"idempotentReplay": true`. The first request returns HTTP `201`.

If the same transaction hash is submitted with different identifying data, the API returns HTTP `409`. Treat that as a data conflict requiring review; do not retry it as a new conversion. Concurrent requests are protected by the same database uniqueness constraint, so they cannot create duplicate conversions or rewards.

**Token-Denominated Purchase (amounts in APE, converted to USD at ingest):**

```bash
curl -X POST https://api.replycorp.io/api/v1/campaigns/YOUR_CAMPAIGN_ID/conversions \
  -H "X-API-Key: rweb_basic_replycorp_tGF9jIsFpQJPkcUUwQqkaYqG" \
  -H "Content-Type: application/json" \
  -d '{
    "twitterId": "1234567890",
    "eventType": "purchase",
    "currency": "APE",
    "amount": 150.0,
    "commission": 3.0
  }'
```

The response includes an `fx` object with the applied APE/USD rate; `conversion.conversionValue`, attribution amounts, and influence points are all calculated from the converted USD values. For `refund` events posted with `originalConversionId`, the rate stored on the original conversion is reused so the USD reversal matches what was originally credited.

**Waitlist Signup (no amount or commission needed):**

```bash
curl -X POST https://api.replycorp.io/api/v1/campaigns/YOUR_CAMPAIGN_ID/conversions \
  -H "X-API-Key: rweb_basic_replycorp_tGF9jIsFpQJPkcUUwQqkaYqG" \
  -H "Content-Type: application/json" \
  -d '{
    "twitterId": "1234567890",
    "eventType": "signup"
  }'
```

**With Custom Metadata:**

```bash
curl -X POST https://api.replycorp.io/api/v1/campaigns/YOUR_CAMPAIGN_ID/conversions \
  -H "X-API-Key: rweb_basic_replycorp_tGF9jIsFpQJPkcUUwQqkaYqG" \
  -H "Content-Type: application/json" \
  -d '{
    "twitterId": "1234567890",
    "eventType": "lock",
    "amount": 100.00,
    "commission": 20.00,
    "walletAddress": "0xabc123def456ghi789jkl012mno345pqr678stu901vwx234yz",
    "metadata": {
      "type": "initial_balance",
      "source": "admin_credit",
      "notes": "Initial balance for new user"
    }
  }'
```

**Success Response (201 Created):**

```json
{
  "conversion": {
    "id": "conv_123456",
    "conversionType": "lock",
    "eventType": "lock",
    "conversionValue": 123.45,
    "convertedAt": "2025-01-15T10:30:00.000Z",
      "participant": {
        "id": "part_123456",
        "twitterId": "1234567890",
        "twitterHandle": "@user_handle",
        "hasEngaged": true,
        "hasConnected": true
      }
  },
  "attribution": {
    "pointsDistributed": true,
    "totalPointsDistributed": 1235,
    "firstDegreeCount": 2,
    "secondDegreeCount": 5,
    "attributions": [
      {
        "twitterId": "2345678901",
        "twitterHandle": "@influencer1",
        "walletAddress": "0x2222222222222222222222222222222222222222",
        "attributedAmount": 50.00,
        "attributionPercentage": 40.5,
        "degree": 1
      },
      {
        "twitterId": "3456789012",
        "twitterHandle": "@influencer2",
        "walletAddress": null,
        "attributedAmount": 30.00,
        "attributionPercentage": 24.3,
        "degree": 1
      },
      {
        "twitterId": "4567890123",
        "twitterHandle": "@influencer3",
        "walletAddress": "0x3333333333333333333333333333333333333333",
        "attributedAmount": 43.45,
        "attributionPercentage": 35.2,
        "degree": 2
      }
    ]
  },
  "pendingInfluencePoints": [
    {
      "twitterId": "2345678901",
      "pointsPending": 6885,
      "batchId": "batch_abc123"
    },
    {
      "twitterId": "3456789012",
      "pointsPending": 4131,
      "batchId": "batch_def456"
    },
    {
      "twitterId": "4567890123",
      "pointsPending": 5984,
      "batchId": "batch_ghi789"
    }
  ]
}
```

The `pendingInfluencePoints` field is only included when the campaign has influence points enabled. Points start as PENDING and activate after the refund window closes (default 30 days) and brand payment is verified.

**Response Fields:**

| Field                                                                                             | Description                                                                                                                                                                                                                |
| ------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `conversion.id`                                                                                   | Unique identifier for the conversion                                                                                                                                                                                       |
| `conversion.conversionType`                                                                       | Type of conversion event (`lock`, `unlock`, `buy`, `sell`, `purchase`, `refund`)                                                                                                                                           |
| `conversion.eventType`                                                                            | Event type (same as `conversionType`, included for clarity)                                                                                                                                                                |
| `conversion.conversionValue`                                                                      | The event amount (positive for positive events, negative for negative events)                                                                                                                                              |
| `conversion.convertedAt`                                                                          | ISO 8601 timestamp of when the conversion was recorded                                                                                                                                                                     |
| `conversion.participant`                                                                          | Participant information for the user who triggered the event                                                                                                                                                               |
| `conversion.participant.id`                                                                       | Unique identifier for the participant                                                                                                                                                                                      |
| `conversion.participant.twitterId`                                                                | Twitter/X user ID                                                                                                                                                                                                          |
| `conversion.participant.twitterHandle`                                                            | Twitter/X handle                                                                                                                                                                                                           |
| `conversion.participant.hasEngaged`                                                               | Boolean indicating if the buyer has actually engaged with tagged posts (commented, replied, or engaged with posts mentioning your campaign). Only counts actual post interactions, not other ways of joining the campaign. |
| `conversion.participant.hasConnected`                                                             | Boolean indicating if the buyer has connected their X account and wallet                                                                                                                                                   |
| `attribution.pointsDistributed`                                                                   | Boolean indicating if points were distributed to influencers                                                                                                                                                               |
| `attribution.totalPointsDistributed`                                                              | Total points distributed (positive for positive events, negative for negative events)                                                                                                                                      |
| `attribution.firstDegreeCount`                                                                    | Number of 1st degree connections that received attribution                                                                                                                                                                 |
| `attribution.secondDegreeCount`                                                                   | Number of 2nd degree connections that received attribution                                                                                                                                                                 |
| `attribution.attributions`                                                                        | Array of detailed attribution information for each attributed user (new in v1.1.0)                                                                                                                                         |
| `attribution.attributions[].twitterId`                                                            | X (Twitter) account ID of the attributed user                                                                                                                                                                              |
| `attribution.attributions[].twitterHandle`                                                        | X (Twitter) handle of the attributed user                                                                                                                                                                                  |
| `attribution.attributions[].walletAddress`                                                        | Wallet address if connected, `null` if not connected                                                                                                                                                                       |
| `attribution.attributions[].attributedAmount`                                                     | Dollar amount attributed to this user (positive for positive events, negative for negative events)                                                                                                                         |
| `attribution.attributions[].attributionPercentage`                                                | Percentage of transaction amount attributed to this user (0-100, always positive)                                                                                                                                          |
| `attribution.attributions[].degree`                                                               | Attribution degree: `1` for 1st degree connections, `2` for 2nd degree connections                                                                                                                                         |
| `attribution.eligibleAttributions`                                                                | Array of attributions filtered by threshold (if enabled), with percentages recalculated to sum to 100% across eligible users only (new in v1.4.1)                                                                          |
| `attribution.eligibleAttributions[].twitterId`                                                    | X (Twitter) account ID of the eligible attributed user                                                                                                                                                                     |
| `attribution.eligibleAttributions[].twitterHandle`                                                | X (Twitter) handle of the eligible attributed user                                                                                                                                                                         |
| `attribution.eligibleAttributions[].walletAddress`                                                | Wallet address if connected, `null` if not connected                                                                                                                                                                       |
| `attribution.eligibleAttributions[].attributedAmount`                                             | Dollar amount attributed to this user (same as in `attributions` array)                                                                                                                                                    |
| `attribution.eligibleAttributions[].attributionPercentage`                                        | Recalculated percentage that sums to 100% across eligible users only                                                                                                                                                       |
| `attribution.eligibleAttributions[].degree`                                                       | Attribution degree: `1` for 1st degree connections, `2` for 2nd degree connections                                                                                                                                         |
| `pendingInfluencePoints`                                                                          | Array of pending influence point batches (only present when campaign has influence points enabled and attributions exist)                                                                                                  |
| `pendingInfluencePoints[].twitterId`                                                              | X (Twitter) account ID of the influencer receiving points                                                                                                                                                                  |
| `pendingInfluencePoints[].pointsPending`                                                          | Number of influence points pending activation. Calculated from the commission after ReplyCorp fee, proportional to the influencer's attribution share.                                                                     |
| `pendingInfluencePoints[].batchId`                                                                | Unique identifier for the point batch                                                                                                                                                                                      |
| `cancelledInfluencePoints`                                                                        | Present on refund events when `originalConversionId` is provided and batches were cancelled                                                                                                                                |
| `cancelledInfluencePoints.pendingCancelled`                                                       | Number of pending batches cancelled                                                                                                                                                                                        |
| `cancelledInfluencePoints.activeCancelled`                                                        | Number of active points reversed                                                                                                                                                                                           |
| `fx`                                                                                              | Present when the request used a non-USD `currency`. FX audit record; also stored on the conversion (`metadata.fx`).                                                                                                        |
| `fx.currency`                                                                                     | Currency code the request was denominated in (e.g. `APE`)                                                                                                                                                                  |
| `fx.rate`                                                                                         | USD per 1 token — the rate applied to all monetary fields                                                                                                                                                                  |
| `fx.rateSource`                                                                                   | Always `DIA` (spot quote from diadata.org)                                                                                                                                                                                 |
| `fx.diaBlockchain` / `fx.diaAddress`                                                              | Token contract the quote was sourced from                                                                                                                                                                                  |
| `fx.quoteTime`                                                                                    | Timestamp of the DIA quote                                                                                                                                                                                                 |
| `fx.rateReusedFromConversionId`                                                                   | Present on refunds when the rate was reused from the original conversion instead of a fresh quote                                                                                                                          |
| `fx.originalAmount` / `fx.originalTotalVolume` / `fx.originalNetProfit` / `fx.originalCommission` | The token-denominated values as posted, before conversion (`null` when not provided)                                                                                                                                       |
| `fx.convertedAt`                                                                                  | ISO 8601 timestamp of when the conversion to USD was performed                                                                                                                                                             |

**Attributions Array Details:**

The `attributions` array provides detailed information for each user who received attribution from the conversion. This enables partners to calculate revenue share payouts.

**Key Features:**

* **Wallet Addresses**: Returns wallet address if the user has connected their wallet, `null` otherwise
* **Percentage Calculation**: Percentage is always positive (0-100), even for negative events (unlock, sell, refund)
* **Attributed Amount**: Can be negative for negative events (unlock, sell, refund)
* **Empty Array**: Returns empty array `[]` when there are no attributions (isolated users)

**Handling Missing Wallet Addresses:**

When `walletAddress` is `null`, partners can:

1. **Match wallet address on their side**: If they have a wallet linked to the X account, they can map it directly
2. **Hold rewards in smart contract**: Escrow rewards for a fixed period (e.g., 30 days) until wallet address is provided
3. **Register webhook for updates**: Receive webhook notifications when participants register or update their wallet address (coming soon)

**Payout Calculation Example:**

```javascript
// Using attribution percentage
const payoutAmount = transactionAmount * (attribution.attributionPercentage / 100);

// Or using attributed amount directly
const payoutAmount = attribution.attributedAmount;
```

**Eligible Attributions (Threshold Filtering):**

When a campaign has `rewardThreshold` set in its `customBranding` metadata, the response includes an `eligibleAttributions` array. This array contains only users who meet the minimum cumulative attribution points threshold (same threshold used for weekly rewards eligibility). If `rewardThreshold` is not set (or is null/undefined), `eligibleAttributions` will be an empty array.

**Key Features:**

* **Threshold Check**: Users must have cumulative total attribution points >= `rewardThreshold` (from `customBranding`) across all conversions in the campaign
* **Recalculated Percentages**: Percentages in `eligibleAttributions` are recalculated to sum to 100% across eligible users only
* **Original Amounts Preserved**: `attributedAmount` values remain the same as in the `attributions` array
* **Empty Array**: Returns empty array `[]` when `rewardThreshold` is not set or no users meet the threshold
* **Same Threshold**: Uses the same `rewardThreshold` value configured in "Edit Campaign Metadata" for weekly rewards eligibility

**Example with Threshold Enabled:**

If a campaign has `customBranding.rewardThreshold: 100` and a conversion has 3 attributions:

* User A: 150 cumulative points → Eligible
* User B: 50 cumulative points → Not eligible
* User C: 200 cumulative points → Eligible

The `eligibleAttributions` array will contain only User A and User C, with percentages recalculated to sum to 100%.

**Error Responses:**

**400 Bad Request** - Invalid request body:

```json
{
  "error": "twitterId is required and must be a string"
}
```

**400 Bad Request** - Invalid event type:

```json
{
  "error": "Invalid eventType. Must be one of: lock, unlock, buy, sell, purchase, refund"
}
```

**400 Bad Request** - Amount sign mismatch:

```json
{
  "error": "Event type 'unlock' requires a negative amount, but received 50"
}
```

**400 Bad Request** - Zero amount (not applicable to `signup` events):

```json
{
  "error": "amount is required and must be a non-zero number"
}
```

**400 Bad Request** - Unsupported currency:

```json
{
  "error": "Unsupported currency 'DOGE'. Supported currencies: USD, APE"
}
```

**503 Service Unavailable** - FX rate temporarily unavailable (non-USD currency only; retry the request):

```json
{
  "error": "APE/USD rate unavailable: DIA request failed. Please retry."
}
```

**400 Bad Request** - Invalid metadata:

```json
{
  "error": "metadata must be an object if provided"
}
```

**400 Bad Request** - Missing txHash for lock event:

```json
{
  "error": "metadata.txHash is required for lock events and must be a string"
}
```

**400 Bad Request** - Missing txHash for unlock event:

```json
{
  "error": "metadata.txHash is required for unlock events and must be a string"
}
```

**400 Bad Request** - Invalid expiresAt:

```json
{
  "error": "metadata.expiresAt must be a positive number (epoch seconds)"
}
```

**400 Bad Request** - ExpiresAt in the past:

```json
{
  "error": "metadata.expiresAt must be a future timestamp (epoch seconds)"
}
```

**400 Bad Request** - Lock not found for unlock:

```json
{
  "error": "No lock found with txHash: 0xdef456..."
}
```

**409 Conflict** - Lock already unlocked:

```json
{
  "error": "Lock with txHash 0xdef456... has already been unlocked"
}
```

**401 Unauthorized** - Missing or invalid API key:

```json
{
  "error": "API key authentication required"
}
```

**403 Forbidden** - Email not whitelisted (registration) or campaign belongs to different organization:

```json
{
  "error": "Email not whitelisted. Organization registration requires approval. Please contact support to request access."
}
```

**403 Forbidden** - Campaign belongs to different organization:

```json
{
  "error": "Unauthorized: Campaign belongs to different organization"
}
```

**404 Not Found** - Campaign not found:

```json
{
  "error": "Campaign not found"
}
```

**409 Conflict** - Duplicate conversion:

```json
{
  "error": "Conversion already exists for this transaction"
}
```

**500 Internal Server Error**:

```json
{
  "error": "Failed to record conversion"
}
```

#### Identifying Instagram Buyers (Verification Codes)

X buyers are identified by `twitterId`. **Instagram buyers are identified by a verification code** — a unique, single-use discount code that carries the buyer's DM-verified Instagram identity, because Instagram offers no OAuth path your checkout can rely on for public identity.

**How a buyer gets a code:**

1. The buyer is shown a short keyword (e.g. `RC-X7K2MP`) — via ReplyCorp's Social Connect widget with the Instagram provider enabled, or a ReplyCorp-powered Shopify flow — and asked to DM it to a ReplyCorp-controlled Instagram account.
2. ReplyCorp verifies the sender's Instagram identity from the DM and replies with a **unique one-off discount code** prefixed with your campaign handle (e.g. `YOURBRAND-4F9A2C`).
3. The buyer enters that code at your checkout. The code itself is the attribution carrier — no session token, cookie, or cart attribute is needed.

**Two ways to use the code at checkout:**

* **Simple:** post the conversion with `verificationCode` instead of `twitterId` (see the example above). ReplyCorp resolves the identity, attributes the conversion against the buyer's Instagram engagement history, and marks the code redeemed.
* **Resolve first:** if you want to validate the code and apply your own discount before completing checkout, resolve it explicitly:

**Endpoint:** `POST /api/v1/codes/resolve`

**Authentication:** Required (API Key)

**Request Body:**

```json
{
  "code": "YOURBRAND-4F9A2C"
}
```

**Success Response (200 OK):**

```json
{
  "code": "YOURBRAND-4F9A2C",
  "status": "VERIFIED",
  "igUsername": "buyer_handle",
  "igUserId": "17841400000000000",
  "campaignId": "your-campaign-id"
}
```

* `status` is `VERIFIED` (not yet used for a conversion) or `REDEEMED` (already used). Codes remain resolvable after redemption so order-processing retries stay deterministic — check `status` if single-use enforcement matters to your flow.
* Unknown, unverified, or other-organization codes return `404`.
* ReplyCorp does not create discount codes inside your shop (except on ReplyCorp-powered Shopify setups) — the resolve endpoint lets your backend validate the code and apply its own discount, then post the conversion with `verificationCode` as usual.

**Behavior notes:**

* Codes are **campaign-scoped**: a code issued for one campaign is rejected on another.
* Codes are **single-use for attribution**: the first successful conversion marks the code `REDEEMED`.
* If you send both `twitterId` and `verificationCode`, `twitterId` takes precedence for identity (and a stale code will not fail the request).
* Everything else about the conversion — `amount`, `commission`, `transactionHash` idempotency, `currency`, refunds — works identically for Instagram-identified buyers.

#### Supported Currencies

**Endpoint:** `GET /api/v1/conversions/supported-currencies`

**Authentication:** Required (API Key)

Returns the currencies accepted in the conversion API `currency` field. Non-USD (token) amounts are converted to USD at ingest using a DIA spot quote sourced from the listed token contract.

**Success Response (200 OK):**

```json
{
  "currencies": [
    { "code": "USD", "name": "US Dollar", "default": true },
    {
      "code": "APE",
      "name": "ApeCoin",
      "rateSource": "DIA",
      "diaBlockchain": "Ethereum",
      "diaAddress": "0x4d224452801ACEd8B2F0aebE155379bb5D594381"
    }
  ]
}
```

#### How Attribution Works

When you post a conversion:

1. **Buyer Identification**: The system looks up the buyer by their `twitterId` in your campaign's participant list.
2. **Attribution Calculation**: If the buyer is a participant, the system identifies all of their 1st and 2nd degree connections. Attribution is distributed to all connections based on their degree of separation.
3. **Points Distribution**:
   * **Attribution Modes**: Campaigns can use one of two attribution modes (configured via `attributionConfig.pointsSplitMode`):
     * **Whole Mode** (default): Each participant gets 100% of the conversion amount attributed to them
     * **Split Mode**: Attribution is split between upstream (influencers) and downstream (influenced) pools, then distributed proportionally
   * **Positive Events** (lock, buy, purchase):
     * Points are calculated at **10 points per $1** for 1st degree connections (default)
     * Points are calculated at **1.5 points per $1** for 2nd degree connections (default)
     * Example (Whole Mode): A $123.45 lock = 1,235 points to each 1st degree connection, 185 points to each 2nd degree connection
     * Example (Split Mode): A $100 conversion with 80/20 split and 10 upstream 1st degree connections (8 with Person A, 2 with Person B):
       * Upstream pool: $80
       * Each connection: $80 × (1.0 / 10.0) = $8.00
       * Person A: 8 × $8.00 = $64.00 attribution
       * Person B: 2 × $8.00 = $16.00 attribution
   * **Negative Events** (unlock, sell, refund):
     * Negative points are distributed using the same rates and mode
     * Example: A -$50.00 unlock = -500 points to 1st degree, -75 points to 2nd degree (in whole mode)
     * This maintains net attribution tracking (positive points offset by negative points)
   * Points are automatically distributed to all attributed influencers based on their connection degree and campaign configuration
4. **Net Volume Calculation**:
   * Net volume is calculated by summing all `conversionValue` amounts
   * Example: $100 lock + (-$30 unlock) = $70 net volume
   * Attribution points are summed to get net points per influencer

**Note:** If the buyer is not yet a participant in your campaign, a participant record will be created automatically, but no attribution will be calculated until they have connections in the network.

**Split Mode Benefits**: Split mode prevents point dilution when many people comment on comment threads. Instead of each participant getting 100% attribution, the conversion amount is split proportionally among all connections, ensuring that participants with more connections (more influence paths) receive proportionally more attribution.

#### Influence Points Marketplace

When a campaign has influence points enabled (`influencePointsEnabled: true`), conversions automatically create influence point batches for attributed influencers. The `commission` field is required for these campaigns.

**Points economics:**

The brand pays a commission on each conversion. ReplyCorp takes a percentage (default 15%), and the remainder is converted to points for influencers.

Example: $10 sale with $2 commission:

* ReplyCorp fee (15% of commission): $0.30
* Commission for points: $2.00 - $0.30 = $1.70
* Points issued: $1.70 × 10,000 points/$1 = 17,000 points
* Points are distributed proportionally among attributed influencers based on their attribution share

**Points formula:** `commission × (1 − replyCorpFeePct) × attributionShare × influencePointsPerDollar`

**Buyer-loyalty points (paused by default):**

If `BUYER_LOYALTY_RATE` is configured above `0`, buyers who have already linked their X/Twitter account before the purchase (`twitterLinkedAt ≤ convertedAt`) and whose conversion produced at least one attributed poster can receive a small **buyer-loyalty batch**:

* Amount: `BUYER_LOYALTY_RATE` × the ReplyCorp-fee slice of the commission. The default rate is `0`, so no buyer-loyalty points are issued unless explicitly enabled.
* Funded from ReplyCorp's own cut, so it does **not** increase what the brand pays.
* One loyalty batch per conversion, cancelled alongside attribution batches on refund.
* Batch `kind` is `BUYER_LOYALTY` (vs `ATTRIBUTION` for poster batches).

**Points lifecycle:**

1. **PENDING**: Point batches are created immediately when a conversion is recorded. Points are not yet spendable. The batch enters a refund window (configurable per campaign, default 30 days).
2. **Activation**: After the refund window closes AND brand payment is verified, batches transition to ACTIVE and points are credited to the influencer's balance.
3. **Redemption**: Influencers can redeem active points for gift cards in the marketplace. Redemptions use FIFO batch deduction. Internal value: 10,000 points = $1 in gift card face value.
4. **Expiry**: Unredeemed points expire 12 months after being earned.
5. **Cancellation**: If a refund event is recorded with `originalConversionId`, any PENDING batches for that conversion are cancelled and any ACTIVE points are reversed.

**Brand payment rails:**

Point batches stay PENDING until the brand's commission is verified as paid. Each organization picks one rail (configured in the ReplyWeb admin panel):

* **Stripe ACH** — the brand links a US bank account via Stripe Financial Connections; ReplyCorp auto-debits accrued commission every `paymentFrequencyDays` (default 10 days).
* **USDC Prepaid** — the brand deposits USDC on Ethereum mainnet into ReplyCorp's `BrandPrepaidLedger` contract; commission is consumed from the prepaid balance when batches are due to activate. A daily email alert fires if the balance is too low to cover upcoming activations.

Either rail unlocks the same PENDING → ACTIVE points lifecycle.

**Tax compliance and OFAC screening:**

US recipients who cross the IRS reporting threshold (default $600/year, lower for some states) are prompted to complete a W-9 (or W-8BEN for non-US persons) through TaxBandits before redemptions over the threshold can complete. They are also asked for electronic-1099 delivery consent. At redemption time, recipients are screened against the OFAC SDN list; matches are blocked with the error code `OFAC_BLOCKED`.

Campaign settings: `influencePointsPerDollar` (default 10,000), `refundWindowDays` (default 30), ReplyCorp fee percentage (default 15%, configurable per campaign).

#### Lock Expiry and Auto-Unlock

For lock events with expiry timestamps:

1. **Lock Creation**: When a lock event is received with `metadata.expiresAt`, the system schedules a background job to process the expiry exactly when the lock expires.
2. **Manual Unlock**: If a manual unlock event is received before expiry, the scheduled expiry job is automatically canceled to prevent duplicate unlock events.
3. **Automatic Expiry**: When a lock expires:
   * The system checks if the lock has already been manually unlocked
   * If not unlocked, an automatic unlock event is created with `metadata.expired: true`
   * Attribution points are distributed (negative points to offset the original lock)
   * The unlock event is processed exactly at expiry time (or very close, depending on Redis/BullMQ precision)
4. **Duplicate Prevention**: The system prevents duplicate unlocks by:
   * Requiring `txHash` for both lock and unlock events
   * Checking for existing unlock events before creating new ones
   * Canceling scheduled expiry jobs when manual unlocks occur
   * Using database transactions to handle race conditions

**Example Lock Expiry Flow:**

* Lock created with `expiresAt: 1735689600` (3 months from now)
* System schedules job to execute at that timestamp
* If user manually unlocks before expiry → scheduled job is canceled
* If lock expires without manual unlock → auto-unlock event created automatically

#### Campaign Admin Endpoints

In addition to the conversion endpoint, the same `X-API-Key` can manage campaign-level settings.

**Update campaign settings**

**Endpoint:** `PATCH /api/v1/campaigns/{campaignId}`

**Selected updatable fields:**

| Field                    | Type           | Description                                                                                                                                                                                            |
| ------------------------ | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `mentionHandle`          | string         | X/Twitter handle the campaign tracks (without `@`).                                                                                                                                                    |
| `autoMessagesEnabled`    | boolean        | Toggle auto rank-reply / welcome messages. BASIC plan accounts cannot enable this.                                                                                                                     |
| `attributionConfig`      | object         | `pointsSplitMode` (`"whole"` or `"split"`) and `pointsSplitConfig` (`upstreamRatio`, `downstreamRatio`, `degreeWeights.first`, `degreeWeights.second`). See "How Attribution Works" above.             |
| `brandContentGuidelines` | object \| null | Per-post content rules used to gate point issuance. Shape: `{ disclosureFormat?, requiredPhrases?: string[], bannedPhrases?: string[], supportingCopy?, commissionSummary? }`. Set to `null` to clear. |

**Example — set brand content guidelines:**

```bash
curl -X PATCH https://api.replycorp.io/api/v1/campaigns/YOUR_CAMPAIGN_ID \
  -H "X-API-Key: rweb_basic_replycorp_tGF9jIsFpQJPkcUUwQqkaYqG" \
  -H "Content-Type: application/json" \
  -d '{
    "brandContentGuidelines": {
      "requiredPhrases": ["#ad", "sponsored"],
      "bannedPhrases": ["guaranteed returns"],
      "supportingCopy": "Tag @yourbrand and mention #ad for posts to qualify."
    }
  }'
```

When `brandContentGuidelines` is set, posts that don't satisfy the phrase rules are still counted as engagement but are filtered out at point-issuance time — those posters won't earn points for the affected conversions.

**Banned poster accounts**

A campaign can maintain a per-campaign blocklist of X accounts that are excluded from earning points (engagement is still tracked).

**List banned accounts:** `GET /api/v1/campaigns/{campaignId}/banned-poster-accounts`

**Add a banned account:** `POST /api/v1/campaigns/{campaignId}/banned-poster-accounts`

Request body accepts either:

```json
{ "twitterHandle": "spammer123" }
```

or:

```json
{ "twitterId": "1234567890" }
```

**Remove a banned account:** `DELETE /api/v1/campaigns/{campaignId}/banned-poster-accounts/{accountId}`

**Resolve a Twitter handle**

Use this to verify that a handle exists on X before banning it or referencing it elsewhere.

**Endpoint:** `GET /api/v1/campaigns/{campaignId}/resolve-twitter-handle?handle=username`

Returns the user's numeric Twitter ID and canonical handle on success, or a 404 if the handle is not found.

#### Backward Compatibility

The API maintains full backward compatibility with existing integrations:

* **Omitting `eventType`**: If `eventType` is not provided, it defaults to `"purchase"`
* **Positive amounts only**: When `eventType` is omitted, the API still requires positive amounts (existing behavior)
* **Response format**: The response includes both `conversionType` and `eventType` fields, but existing consumers can continue using `conversionType`
* **Numeric fields** (`amount`, `commission`): Accept JSON numbers or string-encoded numbers. Use strings when you need to preserve precision for large token amounts (e.g. `"123456789012345.6789"`).

#### Campaign Points Configuration

ReplyCorp uses a temporal, append-only configuration system for campaign points settings. This design provides several powerful capabilities:

**How It Works:**

* Campaign points configurations are stored in a database table with rows for each campaign
* When you update attribution settings, **new rows are added** instead of updating existing ones
* The system always uses the **current active configuration** when calculating attribution
* All historical configurations are preserved for analysis and backtesting

**Key Benefits:**

1. **Temporal Queries**: You can query attribution calculations as they were at any point in time. When a conversion is recorded, the system uses the active configuration at that moment, and you can later see exactly what configuration was applied.
2. **Multi-Layer Functionality**: The `degreeOfAttribution` becomes a field in each configuration row, not a global constant. This means you can have different attribution degrees for different connection layers (1st degree, 2nd degree, etc.) and change them independently over time.
3. **Complete History**: Every configuration change is preserved, allowing you to:
   * See how attribution rules evolved over time
   * Backtest different configurations to model outcomes
   * Audit attribution decisions by viewing the exact configuration that was active when each conversion was recorded
   * Compare the impact of different attribution strategies

**Example Use Case:**

If you change your attribution configuration from "first degree only" to "first and second degree with 50/50 split", the new configuration is added as a new row. All conversions recorded after that point will use the new configuration, while conversions recorded before will retain their original attribution based on the previous configuration. This allows you to analyze the impact of the change and even model what would have happened if you had used different settings.

#### Integration Examples

**JavaScript/Node.js**

```javascript
async function recordConversion(campaignId, twitterId, { eventType, amount, commission, walletAddress }) {
  const body = {
    twitterId,
    amount,
    ...(eventType && { eventType }),
    ...(commission != null && { commission }),
    ...(walletAddress && { walletAddress }),
  };

  const response = await fetch(
    `https://api.replycorp.io/api/v1/campaigns/${campaignId}/conversions`,
    {
      method: 'POST',
      headers: {
        'X-API-Key': 'rweb_basic_replycorp_tGF9jIsFpQJPkcUUwQqkaYqG',
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(body)
    }
  );

  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.error || 'Failed to record conversion');
  }

  return await response.json();
}

// Standard purchase with commission (influence points campaign)
try {
  const result = await recordConversion(
    'YOUR_CAMPAIGN_ID',
    '1234567890',
    { amount: 100.00, commission: 2.00, walletAddress: '0xabc123...' }
  );
  console.log('Purchase recorded:', result);
  console.log('Points pending:', result.pendingInfluencePoints);
} catch (error) {
  console.error('Error:', error.message);
}

// Token lock with commission
try {
  const result = await recordConversion(
    'YOUR_CAMPAIGN_ID',
    '1234567890',
    {
      eventType: 'lock',
      amount: 123.45,
      commission: 24.69,
      walletAddress: '0xabc123...'
    }
  );
  console.log('Lock recorded:', result);
} catch (error) {
  console.error('Error:', error.message);
}
```

**Python**

```python
import requests

def record_conversion(campaign_id, twitter_id, event_type, amount, commission=None, wallet_address=None):
    url = f"https://api.replycorp.io/api/v1/campaigns/{campaign_id}/conversions"
    headers = {
        "X-API-Key": "rweb_basic_replycorp_tGF9jIsFpQJPkcUUwQqkaYqG",
        "Content-Type": "application/json"
    }
    data = {
        "twitterId": twitter_id,
        "eventType": event_type,
        "amount": amount
    }
    if commission is not None:
        data["commission"] = commission
    if wallet_address:
        data["walletAddress"] = wallet_address
    
    response = requests.post(url, json=data, headers=headers)
    response.raise_for_status()
    return response.json()

# Usage - Purchase with commission (influence points campaign)
try:
    result = record_conversion(
        campaign_id="YOUR_CAMPAIGN_ID",
        twitter_id="1234567890",
        event_type="purchase",
        amount=100.00,
        commission=2.00,
        wallet_address="0xabc123..."
    )
    print("Purchase recorded:", result)
except requests.exceptions.HTTPError as e:
    print("Error:", e.response.json())

# Usage - Negative event (unlock)
try:
    result = record_conversion(
        campaign_id="YOUR_CAMPAIGN_ID",
        twitter_id="1234567890",
        event_type="unlock",
        amount=-50.00,  # Negative amount for unlock
        wallet_address="0xabc123..."
    )
    print("Unlock recorded:", result)
except requests.exceptions.HTTPError as e:
    print("Error:", e.response.json())

# Backward compatible usage (omits eventType, defaults to 'purchase')
def record_purchase(campaign_id, twitter_id, amount, wallet_address=None):
    return record_conversion(campaign_id, twitter_id, None, amount, wallet_address)
```

**PHP**

```php
<?php
function recordConversion($campaignId, $twitterId, $eventType, $amount, $walletAddress = null) {
    $url = "https://api.replycorp.io/api/v1/campaigns/{$campaignId}/conversions";
    $headers = [
        "X-API-Key: rweb_basic_replycorp_tGF9jIsFpQJPkcUUwQqkaYqG",
        "Content-Type: application/json"
    ];
    $data = [
        "twitterId" => $twitterId,
        "eventType" => $eventType, // 'lock', 'unlock', 'buy', 'sell', 'purchase', 'refund'
        "amount" => $amount // Positive for positive events, negative for negative events
    ];
    if ($walletAddress) {
        $data["walletAddress"] = $walletAddress;
    }
    
    $ch = curl_init($url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_POST, true);
    curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
    curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
    
    $response = curl_exec($ch);
    $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    
    if ($httpCode !== 201) {
        $error = json_decode($response, true);
        throw new Exception($error['error'] ?? 'Failed to record conversion');
    }
    
    return json_decode($response, true);
}

// Usage - Positive event (lock)
try {
    $result = recordConversion(
        "YOUR_CAMPAIGN_ID",
        "1234567890",
        "lock",
        123.45,
        "0xabc123..."
    );
    echo "Lock recorded: " . json_encode($result);
} catch (Exception $e) {
    echo "Error: " . $e->getMessage();
}

// Usage - Negative event (unlock)
try {
    $result = recordConversion(
        "YOUR_CAMPAIGN_ID",
        "1234567890",
        "unlock",
        -50.00, // Negative amount for unlock
        "0xabc123..."
    );
    echo "Unlock recorded: " . json_encode($result);
} catch (Exception $e) {
    echo "Error: " . $e->getMessage();
}

// Backward compatible usage (omits eventType, defaults to 'purchase')
function recordPurchase($campaignId, $twitterId, $amount, $walletAddress = null) {
    return recordConversion($campaignId, $twitterId, null, $amount, $walletAddress);
}
?>
```

***

### Deprecated Features

#### Leaderboard Endpoints (Deprecated in v1.5.0)

**Status:** Deprecated as of v1.5.0

The following leaderboard endpoints have been deprecated and will be removed in a future version:

* `GET /api/v1/campaigns/{campaignId}/leaderboard-pull`
* `GET /api/v1/campaigns/{campaignId}/leaderboard`

**Access requirements:**

* `GET /api/v1/campaigns/{campaignId}/leaderboard-pull` requires authentication for every campaign, including public campaigns. API-key access additionally requires the [Growth tier or above](/reporting-api-and-mcp.md). Use the campaign organization's API key in `X-API-Key` (the existing `api_key` query parameter is also supported), or an authorized organization session cookie. Organization members must have access to the requested campaign. Validated support sessions are limited to the selected organization.
* `GET /api/v1/campaigns/{campaignId}/participants/{participantId}/conversions` has the same authentication and campaign-access requirements. The participant must belong to the requested campaign.
* Missing or invalid authentication on these protected endpoints returns `401`. Authenticated callers without campaign access receive `403`.
* `GET /api/v1/campaigns/{campaignId}/leaderboard` and `GET /api/v1/campaigns/{campaignId}/leaderboard-public` allow anonymous access to active public campaigns. Their responses omit email and wallet fields. Private campaigns require an authorized organization identity (`403` otherwise); inactive campaigns require that identity as well (`400` otherwise). Authorized organization users may inspect private and inactive campaigns.

For public ranking displays, use `leaderboard-public`. The protected `leaderboard-pull` response continues to include available email and wallet values for authorized integrations. These endpoints remain operational; deprecation does not mean they return `410 Gone`.

**Migration Path:** If you are currently using leaderboard endpoints, please contact <support@replycorp.io> for migration assistance. We recommend using the conversion tracking API and attribution data to build custom leaderboard displays based on your specific needs.

**Timeline:** These endpoints will continue to function but are no longer maintained. They will be removed in a future major version release.

***

### Error Handling

#### Common Error Codes

| Status Code | Meaning               | Description                                                      |
| ----------- | --------------------- | ---------------------------------------------------------------- |
| 400         | Bad Request           | Invalid request parameters or body                               |
| 401         | Unauthorized          | Missing or invalid API key                                       |
| 403         | Forbidden             | Access denied (e.g., campaign belongs to different organization) |
| 404         | Not Found             | Resource not found (campaign, participant, etc.)                 |
| 409         | Conflict              | Duplicate conversion or resource conflict                        |
| 500         | Internal Server Error | Server error occurred                                            |

#### Error Response Format

All error responses follow this format:

```json
{
  "error": "Error message describing what went wrong"
}
```

#### Handling Errors in Your Code

**JavaScript Example:**

```javascript
async function recordConversion(campaignId, twitterId, amount, commission) {
  try {
    const response = await fetch(
      `https://api.replycorp.io/api/v1/campaigns/${campaignId}/conversions`,
      {
        method: 'POST',
        headers: {
          'X-API-Key': API_KEY,
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({ twitterId, amount, commission })
      }
    );

    if (!response.ok) {
      const error = await response.json();
      
      switch (response.status) {
        case 400:
          console.error('Invalid request:', error.error);
          break;
        case 401:
          console.error('Authentication failed. Check your API key.');
          break;
        case 403:
          console.error('Access denied:', error.error);
          break;
        case 404:
          console.error('Campaign not found');
          break;
        case 409:
          console.error('Duplicate conversion:', error.error);
          break;
        default:
          console.error('Server error:', error.error);
      }
      
      throw new Error(error.error);
    }

    return await response.json();
  } catch (error) {
    console.error('Failed to record conversion:', error);
    throw error;
  }
}
```

***

### Rate Limits

#### Current Limits

* **Conversion API**: No specific rate limit (subject to fair use)
* **Reporting API reads** ([Growth+](/reporting-api-and-mcp.md)): 600 requests per hour per organization, reported via standard `RateLimit-*` headers

#### Best Practices

1. **Batch Conversions**: If recording multiple conversions, make sequential requests rather than parallel to avoid overwhelming the system.
2. **Error Retry**: Implement exponential backoff for retries on 500 errors.

**Example Retry Logic:**

```javascript
async function recordConversionWithRetry(campaignId, twitterId, amount, maxRetries = 3) {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await recordConversion(campaignId, twitterId, amount);
    } catch (error) {
      if (attempt === maxRetries) throw error;
      
      // Exponential backoff: wait 2^attempt seconds
      const delay = Math.pow(2, attempt) * 1000;
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
}
```

***

### Support

#### Getting Help

* **Email**: <support@replycorp.io>
* **Documentation**: <https://docs.replycorp.io>
* **Status Page**: <https://status.replycorp.io>

#### Reporting Issues

When reporting issues, please include:

1. API endpoint you're calling
2. Request headers (mask your API key)
3. Request body
4. Response status code and body
5. Timestamp of the request

***

### Changelog

#### October 16, 2026

* **Documented Instagram buyer identification** (the capability itself predates this entry): the conversion endpoint accepts `verificationCode` as an alternative to `twitterId` for buyers verified through ReplyCorp's Instagram DM flow, and `POST /api/v1/codes/resolve` returns the verified Instagram identity behind a code. See [Identifying Instagram Buyers](#identifying-instagram-buyers-verification-codes).
* Updated combined Conversion Intelligence + Rewards guidance: current setups use a single organization with Rewards as an add-on; dual reporting applies to legacy two-organization customers only.

#### Version 2.3.0 (October 2026)

* **Reporting API v1 (Growth+)**: programmatic read access to campaign stats, stats history, per-post analytics, recent conversions, conversion events with attribution, AI insights, and post engagers, using the same `X-API-Key`. See [Reporting API & MCP](/reporting-api-and-mcp.md).
* **MCP server**: built-in Model Context Protocol endpoint at `/api/v1/mcp` (Streamable HTTP) exposing the reporting reads as 8 agent tools, so AI assistants can pull live campaign insights. Growth+, read-only.
* **Tier gating of read endpoints**: API-key access to read/reporting endpoints (including the engagement read endpoints and `leaderboard-pull`) now requires the Growth tier or above; denied requests return `403` with `"code": "plan_capability_denied"`. Conversion posting (`POST /conversions`), conversion reconciliation (`GET /conversions/{conversionId}`), Instagram DM verification, and code resolution are unaffected and remain available on every tier. Dashboard sessions are never tier-gated.
* Reporting reads are rate-limited to 600 requests/hour per organization.

#### September 11, 2026

* Clarify leaderboard authentication, campaign access, public ranking responses, and participant-conversion access requirements.
* Use `YOUR_CAMPAIGN_ID` as the campaign placeholder in examples.

#### Version 2.2.0 (September 2026)

* **Token-denominated conversion reporting**: New optional `currency` field on the conversion endpoint (default `USD`). Brands in token-native ecosystems can post `amount` / `commission` in a supported token (starting with `APE`); ReplyCorp converts to USD once at ingest using a DIA spot quote. Points, billing, and payments remain USD-denominated.
  * New `fx` object in the conversion response (and stored on `metadata.fx`) with the applied rate, quote source and timestamp, and original token amounts for accounting/proof
  * Refund events with `originalConversionId` reuse the rate stored on the original conversion, so the USD reversal matches what was credited regardless of token price movement
  * New `GET /api/v1/conversions/supported-currencies` endpoint publishing the supported token list
  * New error responses: `400` for unsupported currencies, `503` when the FX rate is temporarily unavailable (safe to retry)

#### Version 2.1.0 (May 2026)

* **`signup` event type**: New zero-amount event type for tracking waitlist signups and registrations. `amount` is optional (defaults to `0`), `commission` is not required, and influence points are not generated. Attribution is tracked normally, enabling brands to attribute signups to influencers before paid conversions begin.

#### Version 2.0.0 (May 2026)

This is a major release that consolidates the Influence Points Marketplace and adds first-class admin endpoints. There are no breaking changes to the conversion request shape; the version bump reflects the expanded scope and the new product surface around brand payment, tax compliance, and content guidelines.

* **Campaign admin endpoints** (same `X-API-Key`):
  * `PATCH /api/v1/campaigns/{id}` now supports `brandContentGuidelines` (`requiredPhrases`, `bannedPhrases`, `supportingCopy`, `commissionSummary`, `disclosureFormat`), `autoMessagesEnabled`, `attributionConfig` (`pointsSplitMode` + `pointsSplitConfig`), and `mentionHandle`.
  * `GET / POST /api/v1/campaigns/{id}/banned-poster-accounts` and `DELETE /api/v1/campaigns/{id}/banned-poster-accounts/{accountId}` to manage per-campaign poster blocklists.
  * `GET /api/v1/campaigns/{id}/resolve-twitter-handle?handle=...` to verify an X username exists.
* **Brand content guidelines enforcement**: When `brandContentGuidelines` is set on a campaign, posts must include at least one of the `requiredPhrases` and none of the `bannedPhrases` to earn points for an associated conversion. Engagement is still tracked either way; only point issuance is gated.
* **Buyer-loyalty points**: Buyer social-connect rewards are supported by the `BUYER_LOYALTY` batch kind but are paused by default (`BUYER_LOYALTY_RATE=0`). If enabled later, they are funded from ReplyCorp's cut and add no cost to the brand.
* **Brand payment rails**: Documented both supported rails for funding commission — **Stripe ACH** (auto-debit via Financial Connections) and **USDC Prepaid** (deposit into `BrandPrepaidLedger` on Ethereum mainnet). Either rail unblocks the same PENDING → ACTIVE points flow.
* **Tax compliance and OFAC screening**: US recipients are routed through TaxBandits (W-9 / W-8BEN + electronic-1099 delivery consent) when they cross IRS reporting thresholds. Redemptions are screened against the OFAC SDN list; matches are rejected with `OFAC_BLOCKED`.
* **Numeric-string support**: `amount` and `commission` accept JSON numbers or string-encoded numbers, so high-precision token amounts can be sent without floating-point loss.

#### Version 1.8.0 (April 2026)

* **Influence Points Marketplace**: Campaigns can now enable influence points (`influencePointsEnabled`) for automatic point distribution to attributed influencers
  * New required `commission` field: brands provide both `amount` (sale value, for attribution) and `commission` (what the brand pays, for points calculation)
  * Points formula: `commission × (1 − replyCorpFeePct) × attributionShare × pointsPerDollar`
  * New `pendingInfluencePoints` array in conversion response with `twitterId`, `pointsPending`, and `batchId` for each batch created
  * New `originalConversionId` request field for refund events to cancel pending/active point batches from the original conversion
  * New `cancelledInfluencePoints` response field on refund events showing `pendingCancelled` and `activeCancelled` counts
  * Points lifecycle: PENDING (refund window) → ACTIVE (redeemable) → redeemed for gift cards or expired after 12 months
  * Configurable per campaign: `influencePointsPerDollar` (default 10,000), `refundWindowDays` (default 30), ReplyCorp fee percentage (default 15%)

#### Version 1.5.0 (January 2026)

* **Leaderboard Endpoints Deprecated**: Deprecated leaderboard endpoints as part of API simplification
  * `GET /api/v1/campaigns/{campaignId}/leaderboard-pull` - Deprecated
  * `GET /api/v1/campaigns/{campaignId}/leaderboard` - Deprecated
  * These endpoints will continue to function but are no longer maintained
  * Endpoints will be removed in a future major version release
  * Migration assistance available via <support@replycorp.io>
  * Recommendation: Use conversion tracking API and attribution data to build custom leaderboard displays

#### Version 1.4.3 (January 8, 2026)

* **Participant Engagement and Connection Status**: Added participant status fields to conversion endpoint response
  * New `hasEngaged` field in `conversion.participant`: Boolean indicating if the buyer has actually engaged with tagged posts (commented, replied, or engaged with posts mentioning your campaign). Only counts actual post interactions (ReplyWebEvent records), not other ways of joining the campaign.
  * New `hasConnected` field in `conversion.participant`: Boolean indicating if the buyer has connected their X account and wallet (CampaignRegistrant exists with walletConnectedAt)
  * Enables comment & connect incentive feature for customers to reward buyers who have engaged with posts and connected before purchasing
  * Fully backward compatible - existing response fields preserved

#### Version 1.4.2 (January 6, 2026)

* **Auto Messages Control**: Added campaign-level control for automatic messages on X (Twitter)
  * New `autoMessagesEnabled` field in campaign configuration (default: `false`)
  * When `false`, campaigns will not send automatic rank replies or welcome messages on X
  * When `true`, campaigns will send automatic messages as configured
  * **Plan Restriction**: BASIC plan accounts cannot enable auto messages (feature unavailable)
  * STANDARD, PROFESSIONAL, and ENTERPRISE plans can toggle auto messages on/off
  * Configured via campaign update endpoint (`PATCH /api/v1/campaigns/:id`) or admin dashboard
  * Existing campaigns on non-BASIC plans default to `true` (backward compatible)
  * Existing campaigns on BASIC plans default to `false` (feature unavailable)

#### Version 1.4.1 (January 6, 2026)

* **Attribution Points Threshold**: Added campaign-level threshold filtering for attribution eligibility
  * Uses existing `rewardThreshold` field from `customBranding` metadata (same threshold used for weekly rewards eligibility)
  * New `eligibleAttributions` array in conversion response: contains only users who meet the threshold (only present when `rewardThreshold` is set)
  * Percentages in `eligibleAttributions` are recalculated to sum to 100% across eligible users only
  * Threshold is based on cumulative attribution points across all conversions in the campaign
  * Returns empty `eligibleAttributions` array when `rewardThreshold` is not set or no users meet the threshold
  * Fully backward compatible - existing `attributions` array unchanged
  * Configured via "Edit Campaign Metadata" page (no separate threshold field needed)

#### Version 1.4.0 (January 6, 2026)

* **Attribution Payout Schema**: Added detailed attribution information to conversion endpoint response
  * New `attributions` array in `attribution` object containing detailed information for each attributed user
  * Each attribution includes: `twitterId`, `twitterHandle`, `walletAddress`, `attributedAmount`, `attributionPercentage`, and `degree`
  * Enables partners to calculate revenue share payouts programmatically
  * `walletAddress` is `null` when user hasn't connected their wallet
  * Attribution percentages are always positive (0-100), even for negative events
  * Empty attributions return empty array `[]` for isolated users
  * Fully backward compatible - all existing response fields preserved

#### Version 1.3.0 (December 30, 2025)

* **Attribution Split Configuration**: Added campaign-level configuration to split attribution points between upstream and downstream participants
  * New `pointsSplitMode` field in `attributionConfig`: `"whole"` (default) or `"split"`
  * New `pointsSplitConfig` object with configurable ratios:
    * `upstreamRatio` (default: 0.8) and `downstreamRatio` (default: 0.2) - must sum to 1.0
    * `degreeWeights` with `first` (default: 1.0) and `second` (default: 0.15) multipliers
  * Split mode prevents point dilution when many people comment on comment threads
  * Attribution is calculated per-connection first, then aggregated by participant
  * Backward compatible: defaults to `"whole"` mode for existing campaigns
  * Updated campaign update endpoint (`PATCH /api/v1/campaigns/:id`) to support new configuration fields

#### Version 1.2.0 (December 26, 2025)

* **Lock Expiry and Auto-Unlock**: Added automatic expiry handling for lock events
  * Lock events now support `metadata.txHash` (required) and `metadata.expiresAt` (optional, epoch seconds)
  * Unlock events now require `metadata.txHash` to reference the lock being unlocked
  * System automatically creates unlock events when locks expire if they haven't been manually unlocked
  * Manual unlocks cancel scheduled expiry jobs to prevent duplicate unlocks
  * Expiry jobs are processed exactly at expiry time using BullMQ delayed jobs

#### Version 1.1.0 (December 19, 2025)

* **Event-Based Conversion API**: Added support for multiple event types (`lock`, `unlock`, `buy`, `sell`, `purchase`, `refund`) with positive and negative amounts
* **Enhanced Registrants Endpoint**: Updated `/registrants/user` endpoint to:
  * Return connection data (firstDegree, secondDegree, total)
  * Include attribution data (attributedSales, points) calculated using `calculateAttributionScore`

#### Version 1.0.0

* Initial release of ReplyCorp API
* Conversion tracking endpoint
* API key authentication

***

### Appendix

#### Campaign ID Format

Campaign IDs are UUIDs. Replace `YOUR_CAMPAIGN_ID` in examples with the ID of your campaign:

```
YOUR_CAMPAIGN_ID
```

#### Twitter ID Format

Twitter/X user IDs are numeric strings:

```
"1234567890"
```

To get a user's Twitter ID, you can use Twitter's API or third-party tools.

#### Wallet Address Formats

* **EVM Wallets**: Start with `0x` and are 42 characters long
  * Example: `0xabc123def456ghi789jkl012mno345pqr678stu901vwx234yz`
* **Solana Wallets**: Base58 encoded, typically 32-44 characters
  * Example: `So11111111111111111111111111111111111111112`

***

**Last Updated:** May 2026 (Version 2.1.0)
