# Basketloot API and Offerwall Integration Guide

## Overview

The Basketloot API manages custom offers, tests and synchronizes configured
provider feeds, and accepts signed custom-offer completion postbacks.

- Production base URL: `https://basketloot.com/api/v1`
- Local base URL: `http://127.0.0.1:8010/api/v1`
- Management authentication: `X-Basketloot-Key` header
- Content type: `application/json`
- Default API limit: 60 requests per minute per client IP
- Custom postback limit: 120 requests per minute per client IP

Configure separate high-entropy values in `.env`:

```dotenv
BASKETLOOT_API_KEY=<management-api-key>
CUSTOM_POSTBACK_SECRET=<custom-postback-signing-secret>
CUSTOM_POSTBACK_ALLOWED_IPS=<partner-ip-or-cidr,partner-ip-or-cidr>
CUSTOM_POSTBACK_REQUIRE_TIMESTAMP=true
CUSTOM_POSTBACK_MAX_AGE_SECONDS=300
```

Never put either secret in browser JavaScript or a public offer URL.

## Standard errors

Validation errors use Laravel's standard JSON structure:

```json
{
  "message": "The given data was invalid.",
  "errors": {
    "name": ["The name field is required."]
  }
}
```

| Status | Meaning | Resolution |
| --- | --- | --- |
| `401` | Missing or invalid management key | Send the configured `X-Basketloot-Key` |
| `403` | Invalid postback signature | Recalculate the HMAC with the configured secret |
| `404` | Offer, provider, campaign, or user not found | Verify the numeric identifier |
| `422` | Validation or provider connection failure | Correct fields or provider configuration |
| `429` | Rate limit exceeded | Retry after the throttle window |

## Custom offer endpoints

### GET `/offers`

Lists custom offers with pagination.

Query parameters:

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `status` | string | No | all | `draft`, `active`, `paused`, or `ended` |
| `per_page` | integer | No | 25 | Page size from 1 to 100 |
| `page` | integer | No | 1 | Page number |

```bash
curl "$BASE_URL/offers?status=active&per_page=25" \
  -H "X-Basketloot-Key: $BASKETLOOT_API_KEY" \
  -H "Accept: application/json"
```

Successful response:

```json
{
  "data": [{
    "id": 42,
    "name": "Puzzle Quest",
    "provider": "Direct partner",
    "reward_points": 250,
    "payout": 2.5,
    "status": "active",
    "category": "games",
    "device": "android",
    "postback_url": "https://basketloot.com/api/v1/postbacks/custom"
  }],
  "meta": {"current_page": 1, "last_page": 1, "per_page": 25, "total": 1}
}
```

### POST `/offers`

Creates a custom offer. Standalone offers require `direct_url`; an offer linked
to an offerwall may use that provider's wall URL.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | Yes | Display name, maximum 140 characters |
| `description` | string | No | Requirements/instructions, maximum 1,000 characters |
| `offerwall_id` | integer | No | Existing provider ID |
| `reward_points` | number | Yes | Server-authoritative member reward |
| `payout` | number | Yes | Internal revenue amount |
| `status` | string | Yes | `draft`, `active`, `paused`, or `ended` |
| `category` | string | Yes | `games`, `other`, `surveys`, or `partners` |
| `device` | string | Yes | `all`, `desktop`, `mobile`, `android`, or `ios` |
| `countries` | string | No | Comma-separated ISO country codes; blank means worldwide |
| `direct_url` | URL | Conditional | Required without `offerwall_id`; supports user placeholders |
| `direct_launch_mode` | string | Yes | `iframe` or `external` |
| `featured`, `is_hot`, `curation_locked` | boolean | No | Merchandising controls |
| `starts_at`, `ends_at` | ISO date-time | No | Visibility window |

Supported URL placeholders are `{user_id}`, `{uid}`, `{subid}`, `{username}`,
and `{email}`, including their uppercase forms.

```bash
curl -X POST "$BASE_URL/offers" \
  -H "X-Basketloot-Key: $BASKETLOOT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name":"Puzzle Quest",
    "description":"Install the app and complete level 10.",
    "reward_points":250,
    "payout":2.50,
    "status":"active",
    "category":"games",
    "device":"android",
    "countries":"US,CA",
    "direct_url":"https://partner.example/offer?subid={user_id}",
    "direct_launch_mode":"external",
    "featured":true
  }'
```

Returns `201 Created` with the created offer under `data`.

### GET `/offers/{campaign}`

```bash
curl "$BASE_URL/offers/42" \
  -H "X-Basketloot-Key: $BASKETLOOT_API_KEY" \
  -H "Accept: application/json"
```

### PATCH `/offers/{campaign}`

Send only changed fields.

```bash
curl -X PATCH "$BASE_URL/offers/42" \
  -H "X-Basketloot-Key: $BASKETLOOT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"reward_points":300,"featured":true}'
```

### DELETE `/offers/{campaign}`

Soft-deletes a custom offer and returns `204 No Content`.

```bash
curl -X DELETE "$BASE_URL/offers/42" \
  -H "X-Basketloot-Key: $BASKETLOOT_API_KEY"
```

## Custom offer completion postback

### GET or POST `/postbacks/custom`

This endpoint does not accept a reward from the partner. Basketloot loads the
campaign's configured reward and payout from its own database, preventing a
caller from increasing the credited amount.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `campaign_id` | integer | Yes | Basketloot custom offer ID |
| `user_id` | integer | Yes | Basketloot member ID passed in the click URL |
| `transaction_id` | string | Yes | Provider-unique completion ID |
| `timestamp` | integer | Yes in production | Current Unix timestamp; rejected outside the configured replay window |
| `signature` | string | Yes | Lowercase SHA-256 HMAC, preferably sent as `X-Basketloot-Signature` |

Build the signed message exactly as:

```text
campaign_id|user_id|transaction_id|timestamp
```

Signature pseudocode:

```text
signature = HMAC_SHA256(message, CUSTOM_POSTBACK_SECRET)
```

Shell example:

```bash
TIMESTAMP=$(date +%s)
MESSAGE="42|1008|partner-conversion-8891|$TIMESTAMP"
SIGNATURE=$(printf '%s' "$MESSAGE" | openssl dgst -sha256 -hmac "$CUSTOM_POSTBACK_SECRET" -hex | awk '{print $2}')

curl -X POST "$BASE_URL/postbacks/custom" \
  -H "Content-Type: application/json" \
  -H "X-Basketloot-Signature: $SIGNATURE" \
  -d "{\"campaign_id\":42,\"user_id\":1008,\"transaction_id\":\"partner-conversion-8891\",\"timestamp\":$TIMESTAMP}"
```

First delivery:

```json
{"data":{"status":"credited","transaction_id":"partner-conversion-8891","user_id":1008,"campaign_id":42,"reward_points":250}}
```

Duplicate delivery is idempotent and returns HTTP 200:

```json
{"data":{"status":"duplicate","transaction_id":"partner-conversion-8891"}}
```

Production callbacks additionally reject inactive, not-yet-started, or expired
campaigns; banned users; stale timestamps; malformed transaction identifiers;
and sources outside `CUSTOM_POSTBACK_ALLOWED_IPS`. The campaign reward is always
loaded server-side and capped by `REWARDS_MAX_OFFER_POINTS`.

For a legacy integration that cannot send timestamps, set
`CUSTOM_POSTBACK_REQUIRE_TIMESTAMP=false` temporarily and sign the original
three-field message. Keep the HMAC secret and IP allowlist enabled, then migrate
the partner to timestamped signatures before enabling the requirement.

## Health check

### GET `/api/health`

Returns `200 {"status":"ok"}` when the application and database are available,
or `503 {"status":"unavailable"}` without leaking connection details. Configure
your uptime monitor to check this endpoint over HTTPS.

## Provider feed operations

Provider feed URL, method, headers, query/body fields, JSON root, field mapping,
postback scheme, secret, and IP allowlist are configured in Admin → Providers.
Use HTTPS provider endpoints. The importer supports GET and POST JSON/form feeds,
mapping nested response fields with dot notation.

For SSRF protection, local/private/reserved/metadata destinations are rejected,
DNS must resolve only to public addresses, and HTTP redirects are not followed.

### POST `/providers/{offerwall}/test`

Tests the saved provider feed without importing offers.

```bash
curl -X POST "$BASE_URL/providers/7/test" \
  -H "X-Basketloot-Key: $BASKETLOOT_API_KEY" \
  -H "Accept: application/json"
```

```json
{"data":{"success":true,"count":128,"message":"Connection successful."}}
```

### POST `/providers/{offerwall}/sync`

Fetches, maps, imports/updates/removes offers, records provider health, and runs
curation for that provider.

```bash
curl -X POST "$BASE_URL/providers/7/sync" \
  -H "X-Basketloot-Key: $BASKETLOOT_API_KEY" \
  -H "Accept: application/json"
```

```json
{"data":{"success":true,"imported":12,"updated":116,"deleted":3,"skipped":0,"received":128,"message":"Synced 12 new, 116 updated, 3 removed, 0 skipped."}}
```

A provider failure returns `422`. Failed feeds remove stale live rows so users
are not shown offers whose source can no longer be verified.

## Built-in provider postbacks

Existing provider callbacks live at `/api/{provider}` and accept the provider's
GET or POST placeholders. Common required normalized fields are `user_id`,
`amount`, `payout`, and a unique `trx`; optional display fields are
`campaign_id`, `campaign_name`, `ip`, and `country_code`.

| Provider path | Signature input |
| --- | --- |
| `/api/adgem` | `verifier` with `adgem_hmac256` |
| `/api/cpxresearch` | `secure_hash` with `cpx_md5` |
| `/api/offertoro` | `sig` with `offertoro_md5` |
| `/api/cpalead` | `password` with `cpalead_password` |
| `/api/admantum` | `secret` |
| `/api/adscendmedia` | `secret` |
| `/api/adwallgate` | `secret` |
| `/api/bitswall` | `secret` |
| `/api/kiwiwall` | `secret` |
| `/api/mediumpath` | `secret` |
| `/api/monlix` | `secret` |
| `/api/notik` | `secret` |
| `/api/revenue` | `secret` |
| `/api/wannads` | `secret` |

Static-secret example:

```bash
curl "https://basketloot.com/api/wannads?user_id=1008&campaign_id=offer-77&campaign_name=Example&amount=125&payout=1.25&trx=conversion-8891&secret=$PROVIDER_SECRET"
```

Security and processing rules:

- Provider-specific secrets and optional CIDR/IP allowlists are configured per offerwall.
- A global postback secret can be required as a fallback from Admin → Settings.
- Unsupported signature schemes fail closed.
- Amounts must be positive and below the configured maximum.
- `(provider, transaction)` is unique; balance, referral commission, lead, and callback log changes share a transaction.
- Callback logging recursively redacts passwords, API keys, tokens, signatures, and secrets.

## Automatic user-feed refresh

The server scheduler synchronizes providers every five minutes. Opening or
reloading `/earn` also calls a locked freshness endpoint; only feeds older than
`USER_OFFER_REFRESH_MINUTES` are requested. When their version changes, the page
reloads once and renders the newly imported offers. This prevents a reload storm
from becoming a provider API storm.

## Testing checklist

1. Save the provider configuration in Admin → Providers.
2. Run the Test API action and confirm `success: true` with a plausible count.
3. Run Sync and confirm imported/updated counts.
4. Open Earn with a country/device matching one of the offers.
5. Open the offer and confirm the member identifier is substituted in the URL.
6. Send a signed postback with a new transaction ID.
7. Confirm one lead, one balance change, and a redacted callback log.
8. Send the same transaction again and confirm it is reported as duplicate.
