Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,48 @@ curl -sS --get "$BUYWHERE_BASE_URL/v1/deals" \
--data-urlencode "limit=10"
```

## TypeScript SDK

Install the official npm package:

```bash
npm install @buywhere/sdk
```

Basic search:

```typescript
import { BuyWhereClient } from "@buywhere/sdk";

const client = new BuyWhereClient(process.env.BUYWHERE_API_KEY!);

const results = await client.search({
q: "wireless headphones",
limit: 5,
in_stock: true,
});

for (const product of results.items) {
console.log(`${product.name} | ${product.currency} ${product.price} | ${product.source}`);
}
```

Price comparison for a known product:

```typescript
const search = await client.search({ q: "Nintendo Switch OLED", limit: 1 });
const product = search.items[0];

if (product) {
const comparison = await client.compare({ product_id: product.id });
console.log(comparison.highlights?.cheapest);
}
```

Full package docs and more examples: [sdk/npm/README.md](sdk/npm/README.md)

Runnable scripts live in [sdk/npm/examples](sdk/npm/examples).

## MCP Integration

BuyWhere is listed in the awesome-mcp-servers registry. Connect to Claude Desktop, Cursor, Windsurf, or any MCP-compatible AI client in seconds.
Expand Down
25 changes: 25 additions & 0 deletions alembic/versions/20260425190000_merge_billing_and_webhook_heads.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""merge billing and webhook heads before affiliate click tracking

Revision ID: 20260425190000
Revises: 20260425153000, 20260425173000
Create Date: 2026-04-25 19:00:00.000000
"""

from typing import Sequence, Union


revision: str = "20260425190000"
down_revision: Union[str, Sequence[str], None] = (
"20260425153000",
"20260425173000",
)
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
pass


def downgrade() -> None:
pass
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""Add affiliate click tracking tables for session-based revenue attribution

Revision ID: 20260425200000
Revises: 20260425190000
Create Date: 2026-04-25 20:00:00.000000
"""

from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB

revision = "20260425200000"
down_revision = "20260425190000"
branch_labels = None
depends_on = None


def upgrade() -> None:
op.create_table(
"affiliate_clicks",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("session_id", sa.String(), nullable=False),
sa.Column("product_id", sa.BigInteger(), nullable=False),
sa.Column("merchant", sa.String(), nullable=False),
sa.Column("platform", sa.String(), nullable=True),
sa.Column("tracking_id", sa.String(), nullable=True),
sa.Column("api_key_id", sa.String(), nullable=True),
sa.Column("agent_id", sa.String(), nullable=True),
sa.Column("affiliate_partner", sa.String(), nullable=True),
sa.Column("destination_url", sa.Text(), nullable=False),
sa.Column("referrer", sa.Text(), nullable=True),
sa.Column("user_agent", sa.Text(), nullable=True),
sa.Column("user_ip", sa.Text(), nullable=True),
sa.Column("country", sa.String(2), nullable=True),
sa.Column(
"clicked_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("idx_affiliate_clicks_session_product", "affiliate_clicks", ["session_id", "product_id"])
op.create_index("idx_affiliate_clicks_merchant", "affiliate_clicks", ["merchant"])
op.create_index("idx_affiliate_clicks_clicked_at", "affiliate_clicks", ["clicked_at"])
op.create_index("idx_affiliate_clicks_api_key_id", "affiliate_clicks", ["api_key_id"])
op.create_index("idx_affiliate_clicks_tracking_id", "affiliate_clicks", ["tracking_id"])
op.create_index("idx_affiliate_clicks_session_id", "affiliate_clicks", ["session_id"])
op.create_index("idx_affiliate_clicks_product_id", "affiliate_clicks", ["product_id"])

op.create_table(
"affiliate_conversions",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("click_id", sa.BigInteger(), nullable=False),
sa.Column("session_id", sa.String(), nullable=False),
sa.Column("product_id", sa.BigInteger(), nullable=False),
sa.Column("merchant", sa.String(), nullable=False),
sa.Column("platform", sa.String(), nullable=True),
sa.Column("tracking_id", sa.String(), nullable=True),
sa.Column("api_key_id", sa.String(), nullable=True),
sa.Column("agent_id", sa.String(), nullable=True),
sa.Column("affiliate_partner", sa.String(), nullable=True),
sa.Column("conversion_revenue", sa.Numeric(12, 4), nullable=True),
sa.Column("currency", sa.String(3), nullable=False, server_default="SGD"),
sa.Column("conversion_type", sa.String(32), nullable=True),
sa.Column("conversion_data", JSONB(), nullable=True),
sa.Column(
"converted_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("idx_affiliate_conversions_click_id", "affiliate_conversions", ["click_id"])
op.create_index("idx_affiliate_conversions_session_id", "affiliate_conversions", ["session_id"])
op.create_index("idx_affiliate_conversions_product_id", "affiliate_conversions", ["product_id"])
op.create_index("idx_affiliate_conversions_merchant", "affiliate_conversions", ["merchant"])
op.create_index("idx_affiliate_conversions_conversion_type", "affiliate_conversions", ["conversion_type"])
op.create_index("idx_affiliate_conversions_converted_at", "affiliate_conversions", ["converted_at"])
op.create_index("idx_affiliate_conversions_api_key_id", "affiliate_conversions", ["api_key_id"])


def downgrade() -> None:
op.drop_table("affiliate_conversions")
op.drop_table("affiliate_clicks")
55 changes: 54 additions & 1 deletion app/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,10 +201,63 @@ async def get_current_api_key(
return api_key


async def get_optional_api_key(
request: Request,
db: AsyncSession = Depends(get_db),
) -> ApiKey | None:
auth_header = request.headers.get("Authorization")
token = None
if auth_header and auth_header.startswith("Bearer "):
token = auth_header[7:]

if not token:
return None

paperclip_key = await resolve_paperclip_agent_key(token, db)
if paperclip_key is not None:
return paperclip_key

payload = decode_access_token(token)
if payload and "key_id" in payload:
key_id = payload["key_id"]
result = await db.execute(
select(ApiKey).where(ApiKey.id == key_id, ApiKey.is_active == True)
)
return result.scalar_one_or_none()

key_hash = hash_key(token)
result = await db.execute(
select(ApiKey).where(ApiKey.key_hash == key_hash, ApiKey.is_active == True)
)
api_key = result.scalar_one_or_none()

if api_key is None:
result = await db.execute(
select(ApiKey).where(
ApiKey.is_active == True,
ApiKey.key_hash.like("$2%"),
)
)
candidates = result.scalars().all()
for candidate in candidates:
if _verify_key_bcrypt(token, candidate.key_hash):
api_key = candidate
break

if api_key is not None:
await db.execute(
update(ApiKey)
.where(ApiKey.id == api_key.id)
.values(last_used_at=datetime.now(timezone.utc))
)

return api_key


async def provision_api_key(
developer_id: str,
name: str,
tier: str = "basic",
tier: str = "free",
db: AsyncSession = None,
rate_limit: int = None,
allowed_origins: list = None,
Expand Down
Loading