From 0b8928174ba961813193df4007a38bcf90ad066d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Mar 2026 14:46:53 +0000 Subject: [PATCH 1/3] feat: migrate from Coinbase Commerce to Coinbase Business Payment Link API Replace the deprecated Commerce Charge API with the new Coinbase Business Payment Link API. This includes: - Add CDP API key JWT auth service (ES256-signed per-request tokens) - Add Payment Link CRUD service (create, list, get, deactivate) - Add admin API endpoints for Payment Link management - Remove legacy Commerce webhook verification (X-CC-Webhook-Signature) - Remove legacy charge:confirmed handler and related code - Update config with CDP_API_KEY_NAME and CDP_API_KEY_PRIVATE_KEY - Remove COINBASE_COMMERCE_WEBHOOK_SECRET from config - Add Payment Link schemas for request/response validation - Update env.example with new Coinbase Business variables - Add backend migration review checklist document The webhook endpoint now only accepts Payment Link API format (X-Hook0-Signature) with timestamp-based replay protection. https://claude.ai/code/session_01F6V8AXKaLG3t7jC8YtqMsR --- ...nbase-business-migration-backend-review.md | 191 ++++++++++++++++ env.example | 15 ++ src/api/v1/billing/admin.py | 184 ++++++++++++++++ src/api/v1/webhooks/coinbase.py | 206 ++---------------- src/core/config.py | 14 +- src/schemas/payment_link.py | 39 ++++ src/services/coinbase_auth.py | 86 ++++++++ src/services/coinbase_payment_link_service.py | 193 ++++++++++++++++ src/services/coinbase_webhook_service.py | 155 +------------ 9 files changed, 739 insertions(+), 344 deletions(-) create mode 100644 docs/coinbase-business-migration-backend-review.md create mode 100644 src/schemas/payment_link.py create mode 100644 src/services/coinbase_auth.py create mode 100644 src/services/coinbase_payment_link_service.py diff --git a/docs/coinbase-business-migration-backend-review.md b/docs/coinbase-business-migration-backend-review.md new file mode 100644 index 00000000..3048e8a5 --- /dev/null +++ b/docs/coinbase-business-migration-backend-review.md @@ -0,0 +1,191 @@ +# Coinbase Commerce to Business Migration - Backend Review Checklist + +**Migration Deadline: March 31, 2026** +**Reference**: [Coinbase Transition Guide](https://help.coinbase.com/en/transitioning-from-coinbase-commerce-to-coinbase-business) + +--- + +## 1. Coinbase Dashboard / Account Setup + +- [ ] **Create Coinbase Business account** (or convert existing Commerce account) + - Currently available in **US and Singapore only** + - [Getting Started](https://docs.cdp.coinbase.com/coinbase-business/introduction/get-started) +- [ ] **Complete KYB (Know Your Business) verification** if not already done +- [ ] **Generate CDP API Key** in the [CDP Portal](https://portal.cdp.coinbase.com/access/api) + - Select **ES256** algorithm + - Enable **View** scope (covers Payment Link CRUD) + - Download the private key PEM file — it's shown only once + - Note the key name format: `organizations/{org_id}/apiKeys/{key_id}` + +--- + +## 2. Environment Variables to Configure + +| Variable | Description | Where | +|---|---|---| +| `CDP_API_KEY_NAME` | Key name from CDP portal (`organizations/{org_id}/apiKeys/{key_id}`) | All environments | +| `CDP_API_KEY_PRIVATE_KEY` | EC private key PEM (newlines as `\n`) | All environments (secrets manager) | +| `COINBASE_PAYMENT_LINK_WEBHOOK_SECRET` | From webhook subscription metadata | All environments | + +### Variables to Remove + +| Variable | Reason | +|---|---| +| `COINBASE_COMMERCE_WEBHOOK_SECRET` | Legacy Commerce API — no longer used | + +--- + +## 3. Webhook Configuration + +- [ ] **Register webhook endpoint** in Coinbase Business dashboard + - URL: `https:///api/v1/webhooks/coinbase` + - Content-Type: `application/json` +- [ ] **Subscribe to events**: + - `payment_link.payment.success` + - `payment_link.payment.failed` + - `payment_link.payment.expired` +- [ ] **Save the webhook secret** from the subscription metadata response + - Set as `COINBASE_PAYMENT_LINK_WEBHOOK_SECRET` env var +- [ ] **Test webhook delivery** using Coinbase's test tools or [Postman collection](https://docs.cdp.coinbase.com/coinbase-business/payment-link-apis/postman-files) + +### Webhook Signature Format (new) +- Header: `X-Hook0-Signature` +- Format: `t=,h=,v1=` +- Replay protection: Rejects events older than 5 minutes + +### Old Format (removed) +- Header: `X-CC-Webhook-Signature` — no longer accepted by our endpoint + +--- + +## 4. API Authentication Changes + +### Old (Commerce) +``` +X-CC-Api-Key: +X-CC-Version: 2018-03-22 +``` + +### New (CDP / Business) +``` +Authorization: Bearer +Content-Type: application/json +``` + +The JWT is generated **per request** with: +- `sub`: CDP key name +- `iss`: `"cdp"` +- `uri`: `"{METHOD} api.coinbase.com{PATH}"` +- `exp`: current time + 120 seconds +- `nonce`: random hex + +Implementation: `src/services/coinbase_auth.py` + +--- + +## 5. Payment Link API Endpoints + +Base URL: `https://api.coinbase.com` + +| Operation | Method | Path | +|---|---|---| +| Create | `POST` | `/api/v1/payment-links` | +| List | `GET` | `/api/v1/payment-links` | +| Get | `GET` | `/api/v1/payment-links/{id}` | +| Deactivate | `POST` | `/api/v1/payment-links/{id}/deactivate` | + +### Key Differences from Commerce Charges + +| Aspect | Commerce (old) | Payment Link (new) | +|---|---|---| +| ID format | UUID | 24-char hex | +| URL field | `hosted_url` | `url` | +| Amount | `pricing.local.amount` | `amount` (flat) | +| Currency | `pricing.local.currency` | `currency` (flat) | +| Status | `timeline` array | Single `status` field | +| Statuses | NEW, SIGNED, PENDING, COMPLETED | ACTIVE, COMPLETED, EXPIRED, DEACTIVATED | +| Currencies | BTC, ETH, USDC, DAI, USD | **USDC only** | +| Network | Multiple | **Base only** | +| Idempotency | Not required | `X-Idempotency-Key` header | + +--- + +## 6. Currency Limitation — Important + +The Payment Link API currently **only supports USDC on Base network**. If users were previously paying with BTC, ETH, or other currencies via Commerce, they will need to use USDC going forward. + +Verify: +- [ ] Frontend payment UI reflects USDC-only +- [ ] Any documentation/help text referencing multi-currency is updated +- [ ] Pricing is displayed in USDC (1:1 with USD) + +--- + +## 7. Database / Data Migration + +No schema changes required — the `credits_ledger` table already supports both formats via the `payment_metadata` JSONB column. + +### Verify +- [ ] Existing `payment_metadata` entries with `"type": "charge"` remain queryable +- [ ] New entries will have `"type": "payment_link"` +- [ ] `external_transaction_id` now stores 24-char hex IDs (was UUID charge codes) +- [ ] No migration script needed for existing data + +--- + +## 8. Testing Checklist + +### Pre-deployment +- [ ] Verify JWT signing works with test CDP key +- [ ] Create a test payment link via admin endpoint +- [ ] Verify webhook signature verification with test payload +- [ ] Test idempotency (same webhook delivered twice) +- [ ] Test expired/failed webhook handling + +### Post-deployment +- [ ] Create a real payment link and complete payment +- [ ] Verify credits appear in user's balance +- [ ] Verify payment metadata is stored correctly +- [ ] Monitor logs for `coinbase_pl_webhook_*` events +- [ ] Verify deactivation works + +### Postman Collection +Coinbase provides a [Postman collection](https://docs.cdp.coinbase.com/coinbase-business/payment-link-apis/postman-files) for testing all Payment Link API endpoints. + +--- + +## 9. Rollback Plan + +If issues are discovered after deployment: + +1. The webhook endpoint only accepts `X-Hook0-Signature` — if you need to revert, restore the legacy `verify_legacy_commerce_signature` function from git history +2. CDP API key credentials can coexist with Commerce API keys during transition +3. The `COINBASE_COMMERCE_WEBHOOK_SECRET` config was removed from code but the env var can remain set harmlessly + +--- + +## 10. IP Allowlisting + +- [ ] If using CDP API key IP allowlisting, ensure all API server IPs are added +- [ ] If behind a load balancer, verify the outbound IP (NAT gateway) is allowlisted + +--- + +## 11. Monitoring & Alerting + +Ensure alerts are configured for these log event types: +- `coinbase_pl_webhook_not_configured` — Secret missing (critical) +- `coinbase_pl_webhook_invalid_signature` — Signature mismatch (security) +- `coinbase_pl_webhook_replay` — Replay attack attempt (security) +- `admin_payment_link_error` — API call failures (operational) + +--- + +## 12. Documentation References + +- [Migration Overview](https://docs.cdp.coinbase.com/coinbase-business/payment-link-apis/migrate/overview) +- [API & Schema Mapping](https://docs.cdp.coinbase.com/coinbase-business/payment-link-apis/migrate/api-schema-mapping) +- [Payment Link API Reference](https://docs.cdp.coinbase.com/api-reference/business-api/rest-api/payment-links/introduction) +- [CDP API Key Auth](https://docs.cdp.coinbase.com/coinbase-business/authentication-authorization/api-key-authentication) +- [Webhook Docs](https://docs.cdp.coinbase.com/coinbase-business/payment-link-apis/webhooks) +- [Migration FAQ](https://docs.cdp.coinbase.com/coinbase-business/payment-link-apis/migrate/faq) diff --git a/env.example b/env.example index 3c7a83a1..22e0b3be 100644 --- a/env.example +++ b/env.example @@ -157,6 +157,21 @@ STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key # The CLI will display the signing secret (whsec_...) STRIPE_WEBHOOK_SECRET=whsec_your_webhook_signing_secret +# ============================================================================= +# COINBASE BUSINESS CONFIGURATION +# ============================================================================= +# CDP API Key for Payment Link CRUD operations +# Generate at: https://portal.cdp.coinbase.com/access/api +# Key name format: organizations/{org_id}/apiKeys/{key_id} +CDP_API_KEY_NAME=organizations/your-org-id/apiKeys/your-key-id +# EC private key in PEM format (replace newlines with \n) +CDP_API_KEY_PRIVATE_KEY="-----BEGIN EC PRIVATE KEY-----\nYOUR_KEY_HERE\n-----END EC PRIVATE KEY-----" + +# Payment Link webhook signature verification secret +# From metadata.secret when creating a webhook subscription +# See: https://docs.cdp.coinbase.com/coinbase-business/payment-link-apis/webhooks +COINBASE_PAYMENT_LINK_WEBHOOK_SECRET=your_webhook_secret + # ============================================================================= # BUILDERS API CONFIGURATION (MOR Staking Data) # ============================================================================= diff --git a/src/api/v1/billing/admin.py b/src/api/v1/billing/admin.py index afef28d3..c065a239 100644 --- a/src/api/v1/billing/admin.py +++ b/src/api/v1/billing/admin.py @@ -24,9 +24,15 @@ RateLimitMultiplierRequest, RateLimitMultiplierResponse, ) +from ....schemas.payment_link import ( + CreatePaymentLinkRequest, + PaymentLinkResponse, + PaymentLinkListResponse, +) from ....core.logging_config import get_api_logger from ....core.config import settings from ....services.cache_service import cache_service +from ....services.coinbase_payment_link_service import coinbase_payment_link_service logger = get_api_logger() @@ -407,3 +413,181 @@ async def get_rate_limit_multiplier( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error getting rate limit multiplier: {str(e)}", ) + + +# === Coinbase Payment Link Endpoints === + + +@admin_router.post("/payment-links", response_model=PaymentLinkResponse, tags=["Payment Links"]) +async def create_payment_link( + request: CreatePaymentLinkRequest, + current_user: User = Depends(get_current_user), + _admin_verified: bool = Depends(verify_billing_admin_secret), +): + """ + Create a Coinbase Business Payment Link. + + **Admin endpoint** - Requires X-Admin-Secret header. + + Creates a USDC payment link via the Coinbase Business API. + The user_id of the authenticated user is automatically added to metadata + so the webhook can credit the correct account. + """ + try: + # Inject user_id into metadata for webhook correlation + metadata = request.metadata or {} + metadata["user_id"] = current_user.cognito_user_id + + result = await coinbase_payment_link_service.create_payment_link( + amount=request.amount, + currency=request.currency, + metadata=metadata, + description=request.description, + success_redirect_url=request.success_redirect_url, + failure_redirect_url=request.failure_redirect_url, + expires_at=request.expires_at, + ) + + logger.info( + "Payment link created via admin API", + user_id=current_user.id, + payment_link_id=result.get("id"), + amount=request.amount, + currency=request.currency, + event_type="admin_payment_link_created", + ) + + return result + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=str(e), + ) + except Exception as e: + logger.error( + "Error creating payment link", + error=str(e), + error_type=type(e).__name__, + event_type="admin_payment_link_error", + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error creating payment link: {str(e)}", + ) + + +@admin_router.get("/payment-links", response_model=PaymentLinkListResponse, tags=["Payment Links"]) +async def list_payment_links( + limit: int = Query(default=25, ge=1, le=100, description="Max results per page"), + cursor: Optional[str] = Query(default=None, description="Pagination cursor"), + link_status: Optional[str] = Query(default=None, alias="status", description="Filter by status"), + current_user: User = Depends(get_current_user), + _admin_verified: bool = Depends(verify_billing_admin_secret), +): + """ + List Coinbase Business Payment Links. + + **Admin endpoint** - Requires X-Admin-Secret header. + + Returns a paginated list of payment links with optional status filtering. + Status values: ACTIVE, COMPLETED, EXPIRED, DEACTIVATED. + """ + try: + result = await coinbase_payment_link_service.list_payment_links( + limit=limit, + cursor=cursor, + status=link_status, + ) + return result + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=str(e), + ) + except Exception as e: + logger.error( + "Error listing payment links", + error=str(e), + error_type=type(e).__name__, + event_type="admin_payment_link_list_error", + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error listing payment links: {str(e)}", + ) + + +@admin_router.get("/payment-links/{payment_link_id}", response_model=PaymentLinkResponse, tags=["Payment Links"]) +async def get_payment_link( + payment_link_id: str, + current_user: User = Depends(get_current_user), + _admin_verified: bool = Depends(verify_billing_admin_secret), +): + """ + Get a specific Coinbase Business Payment Link by ID. + + **Admin endpoint** - Requires X-Admin-Secret header. + """ + try: + result = await coinbase_payment_link_service.get_payment_link(payment_link_id) + return result + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=str(e), + ) + except Exception as e: + logger.error( + "Error getting payment link", + payment_link_id=payment_link_id, + error=str(e), + error_type=type(e).__name__, + event_type="admin_payment_link_get_error", + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error getting payment link: {str(e)}", + ) + + +@admin_router.post("/payment-links/{payment_link_id}/deactivate", response_model=PaymentLinkResponse, tags=["Payment Links"]) +async def deactivate_payment_link( + payment_link_id: str, + current_user: User = Depends(get_current_user), + _admin_verified: bool = Depends(verify_billing_admin_secret), +): + """ + Deactivate a Coinbase Business Payment Link. + + **Admin endpoint** - Requires X-Admin-Secret header. + + Prevents further payments on this link. Cannot be undone. + """ + try: + result = await coinbase_payment_link_service.deactivate_payment_link(payment_link_id) + + logger.info( + "Payment link deactivated via admin API", + user_id=current_user.id, + payment_link_id=payment_link_id, + event_type="admin_payment_link_deactivated", + ) + + return result + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=str(e), + ) + except Exception as e: + logger.error( + "Error deactivating payment link", + payment_link_id=payment_link_id, + error=str(e), + error_type=type(e).__name__, + event_type="admin_payment_link_deactivate_error", + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error deactivating payment link: {str(e)}", + ) diff --git a/src/api/v1/webhooks/coinbase.py b/src/api/v1/webhooks/coinbase.py index c73b5af4..8e5ca084 100644 --- a/src/api/v1/webhooks/coinbase.py +++ b/src/api/v1/webhooks/coinbase.py @@ -1,14 +1,13 @@ """ -Coinbase webhook endpoint for receiving payment events. +Coinbase Business webhook endpoint for receiving Payment Link payment events. -Supports both: -- NEW: Payment Link API webhooks (payment_link.payment.success/failed/expired) - Signature: X-Hook0-Signature header (t=timestamp,h=headers,v1=hmac) - Docs: https://docs.cdp.coinbase.com/coinbase-business/payment-link-apis/webhooks +Supports Payment Link API webhooks: +- payment_link.payment.success: Payment completed +- payment_link.payment.failed: Payment failed +- payment_link.payment.expired: Payment link expired -- LEGACY: Commerce Charge API webhooks (charge:confirmed, etc.) - Signature: X-CC-Webhook-Signature header (HMAC-SHA256 of body) - Will be removed after migration is complete. +Signature: X-Hook0-Signature header (t=timestamp,h=headers,v1=hmac) +Docs: https://docs.cdp.coinbase.com/coinbase-business/payment-link-apis/webhooks """ import hmac import hashlib @@ -33,21 +32,6 @@ # === Signature Verification === -def _detect_webhook_format(request: Request) -> str: - """ - Detect whether the incoming webhook uses the new Payment Link format - or legacy Commerce Charge format based on the signature header. - - Returns: - "payment_link" or "legacy_commerce" - """ - if request.headers.get("x-hook0-signature"): - return "payment_link" - if request.headers.get("x-cc-webhook-signature"): - return "legacy_commerce" - return "unknown" - - async def verify_payment_link_signature( request: Request, body_bytes: bytes ) -> None: @@ -166,67 +150,7 @@ async def verify_payment_link_signature( ) -async def verify_legacy_commerce_signature( - request: Request, body_bytes: bytes -) -> None: - """ - Verify the legacy Commerce Charge API webhook signature (X-CC-Webhook-Signature). - - DEPRECATED: This will be removed after migration to Payment Link API is complete. - - Raises: - HTTPException: If verification fails - """ - if not settings.COINBASE_COMMERCE_WEBHOOK_SECRET: - logger.error( - "Legacy Commerce webhook received but COINBASE_COMMERCE_WEBHOOK_SECRET not configured", - event_type="coinbase_legacy_webhook_not_configured", - ) - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Coinbase Commerce webhook not configured", - ) - - sig_header = request.headers.get("x-cc-webhook-signature") - if not sig_header: - logger.warning( - "Legacy Commerce webhook missing signature header", - event_type="coinbase_legacy_webhook_missing_signature", - ) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Missing X-CC-Webhook-Signature header", - ) - - try: - signature = hmac.new( - settings.COINBASE_COMMERCE_WEBHOOK_SECRET.encode("utf-8"), - body_bytes, - digestmod=hashlib.sha256, - ).hexdigest() - except Exception as e: - logger.error( - "Error computing legacy Commerce signature", - error=str(e), - event_type="coinbase_legacy_webhook_verification_error", - ) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Signature verification error", - ) - - if not hmac.compare_digest(signature, sig_header): - logger.warning( - "Legacy Commerce webhook signature verification failed", - event_type="coinbase_legacy_webhook_invalid_signature", - ) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid signature", - ) - - -# === Payment Link API Webhook Handler (New) === +# === Payment Link API Webhook Handler === async def _handle_payment_link_webhook( @@ -296,7 +220,6 @@ async def _handle_payment_link_webhook( coinbase_payment_link_id=payment_link_id, error=message, ) - # Don't raise 500 for failed payments - just log elif event_type == coinbase_webhook_service.EVENT_TYPE_PL_PAYMENT_EXPIRED: success, message = await coinbase_webhook_service.handle_payment_expired( @@ -309,7 +232,6 @@ async def _handle_payment_link_webhook( coinbase_payment_link_id=payment_link_id, error=message, ) - # Don't raise 500 for expired payments - just log else: logger.info( @@ -321,82 +243,6 @@ async def _handle_payment_link_webhook( return {"received": True} -# === Legacy Commerce Webhook Handler === - - -async def _handle_legacy_commerce_webhook( - payload: dict, - db: AsyncSession, -) -> dict: - """ - Handle a legacy Commerce Charge API webhook event. - - DEPRECATED: Will be removed after migration to Payment Link API is complete. - - Legacy payloads wrap events: - { "id": "delivery-id", "event": { "id": "...", "type": "charge:confirmed", "data": {...} } } - """ - event = payload.get("event") - if not event or not isinstance(event, dict): - logger.warning( - "Invalid legacy Commerce webhook payload (missing 'event' key)", - payload_keys=list(payload.keys()) if isinstance(payload, dict) else None, - event_type="coinbase_legacy_webhook_invalid_format", - ) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid event format: missing 'event' key", - ) - - event_id = event.get("id") - event_type = event.get("type") - - if not event_id or not event_type: - logger.warning( - "Invalid legacy Commerce event format (missing id or type)", - event_keys=list(event.keys()), - event_type="coinbase_legacy_webhook_invalid_format", - ) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid event format: missing id or type", - ) - - logger.info( - "Received legacy Commerce webhook event (consider migrating to Payment Link API)", - coinbase_event_id=event_id, - coinbase_event_type=event_type, - ) - - event_data = event.get("data", {}) - - if event_type == coinbase_webhook_service.EVENT_TYPE_CHARGE_CONFIRMED: - success, message = await coinbase_webhook_service.handle_charge_confirmed( - db=db, - event_data=event_data, - event_id=event_id, - event_type=event_type, - ) - if not success: - logger.error( - "Failed to process charge:confirmed", - coinbase_event_id=event_id, - error=message, - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to process event: {message}", - ) - else: - logger.info( - "Received unhandled legacy Commerce event type", - coinbase_event_id=event_id, - coinbase_event_type=event_type, - ) - - return {"received": True} - - # === Main Endpoint === @@ -406,20 +252,14 @@ async def handle_coinbase_webhook( db: AsyncSession = Depends(get_db_session), ): """ - Handle incoming Coinbase webhook events. + Handle incoming Coinbase Business Payment Link webhook events. - Supports both Payment Link API (new) and legacy Commerce Charge API formats. - Auto-detects the format based on the signature header: - - X-Hook0-Signature → Payment Link API (payment_link.payment.*) - - X-CC-Webhook-Signature → Legacy Commerce (charge:*) + Verifies the X-Hook0-Signature header and routes to the appropriate handler. Payment Link event types: - payment_link.payment.success: Payment completed successfully - payment_link.payment.failed: Payment failed - payment_link.payment.expired: Payment link expired - - Legacy event types (deprecated): - - charge:confirmed: Payment confirmed """ # Read body once for verification and parsing try: @@ -435,22 +275,8 @@ async def handle_coinbase_webhook( detail="Invalid request body", ) - # Detect webhook format and verify signature - webhook_format = _detect_webhook_format(request) - - if webhook_format == "payment_link": - await verify_payment_link_signature(request, body_bytes) - elif webhook_format == "legacy_commerce": - await verify_legacy_commerce_signature(request, body_bytes) - else: - logger.warning( - "Coinbase webhook missing both X-Hook0-Signature and X-CC-Webhook-Signature headers", - event_type="coinbase_webhook_no_signature", - ) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Missing webhook signature header", - ) + # Verify Payment Link signature + await verify_payment_link_signature(request, body_bytes) # Parse JSON body try: @@ -466,19 +292,15 @@ async def handle_coinbase_webhook( detail="Invalid JSON body", ) - # Route to the appropriate handler + # Handle event try: - if webhook_format == "payment_link": - return await _handle_payment_link_webhook(payload, db) - else: - return await _handle_legacy_commerce_webhook(payload, db) + return await _handle_payment_link_webhook(payload, db) except HTTPException: raise except Exception as e: logger.exception( "Error processing Coinbase webhook event", - webhook_format=webhook_format, error=str(e), ) raise HTTPException( diff --git a/src/core/config.py b/src/core/config.py index 00b56e36..12d6f83b 100644 --- a/src/core/config.py +++ b/src/core/config.py @@ -222,14 +222,18 @@ def assemble_cors_origins(cls, v: Union[str, List[str]]) -> Union[List[str], str STRIPE_SECRET_KEY: str | None = Field(default=os.getenv("STRIPE_SECRET_KEY")) STRIPE_WEBHOOK_SECRET: str | None = Field(default=os.getenv("STRIPE_WEBHOOK_SECRET")) - # Coinbase Commerce Settings (Legacy - kept for backward compatibility) - # Required for processing legacy Coinbase Commerce charge webhooks - COINBASE_COMMERCE_WEBHOOK_SECRET: str | None = Field(default=os.getenv("COINBASE_COMMERCE_WEBHOOK_SECRET")) - - # Coinbase Payment Link Settings (New) + # Coinbase Business / CDP Settings + # Payment Link webhook signature verification secret # Secret from metadata.secret returned when creating a webhook subscription # See: https://docs.cdp.coinbase.com/coinbase-business/payment-link-apis/webhooks COINBASE_PAYMENT_LINK_WEBHOOK_SECRET: str | None = Field(default=os.getenv("COINBASE_PAYMENT_LINK_WEBHOOK_SECRET")) + + # CDP API Key credentials for Payment Link CRUD operations + # Key name format: organizations/{org_id}/apiKeys/{key_id} + # See: https://docs.cdp.coinbase.com/coinbase-business/authentication-authorization/api-key-authentication + CDP_API_KEY_NAME: str | None = Field(default=os.getenv("CDP_API_KEY_NAME")) + # EC private key in PEM format (use literal \n for newlines in env vars) + CDP_API_KEY_PRIVATE_KEY: str | None = Field(default=os.getenv("CDP_API_KEY_PRIVATE_KEY")) # Web3 Provider Settings (optional - enables EIP-1271 smart contract wallet verification) # If not set, only EOA wallets will be supported diff --git a/src/schemas/payment_link.py b/src/schemas/payment_link.py new file mode 100644 index 00000000..8bc51e86 --- /dev/null +++ b/src/schemas/payment_link.py @@ -0,0 +1,39 @@ +""" +Pydantic schemas for Coinbase Business Payment Link API. +""" +from typing import Optional, Dict, Any, List +from pydantic import BaseModel, Field + + +class CreatePaymentLinkRequest(BaseModel): + """Request to create a new Coinbase Payment Link.""" + amount: str = Field(..., description="Payment amount (e.g., '100.00')") + currency: str = Field(default="USDC", description="Currency code (currently only USDC)") + metadata: Optional[Dict[str, Any]] = Field(None, description="Key-value pairs passed through the payment flow") + description: Optional[str] = Field(None, description="Description shown on the payment page") + success_redirect_url: Optional[str] = Field(None, description="HTTPS URL to redirect on success") + failure_redirect_url: Optional[str] = Field(None, description="HTTPS URL to redirect on failure") + expires_at: Optional[str] = Field(None, description="ISO 8601 timestamp when the link expires") + + +class PaymentLinkResponse(BaseModel): + """Response from the Coinbase Payment Link API.""" + id: str = Field(..., description="Payment link ID (24-char hex)") + url: Optional[str] = Field(None, description="Payment link URL") + status: Optional[str] = Field(None, description="ACTIVE, COMPLETED, EXPIRED, or DEACTIVATED") + amount: Optional[str] = None + currency: Optional[str] = None + description: Optional[str] = None + metadata: Optional[Dict[str, Any]] = None + created_at: Optional[str] = Field(None, alias="createdAt") + updated_at: Optional[str] = Field(None, alias="updatedAt") + expires_at: Optional[str] = Field(None, alias="expiresAt") + + model_config = {"populate_by_name": True} + + +class PaymentLinkListResponse(BaseModel): + """Paginated list of payment links.""" + data: List[Dict[str, Any]] = Field(default_factory=list) + has_more: Optional[bool] = None + cursor: Optional[str] = None diff --git a/src/services/coinbase_auth.py b/src/services/coinbase_auth.py new file mode 100644 index 00000000..b03da349 --- /dev/null +++ b/src/services/coinbase_auth.py @@ -0,0 +1,86 @@ +""" +CDP (Coinbase Developer Platform) API Key authentication. + +Generates ES256-signed JWTs for authenticating with the Coinbase Business API. +Each request requires a unique JWT with the target URI embedded in the payload. + +Docs: https://docs.cdp.coinbase.com/coinbase-business/authentication-authorization/api-key-authentication +""" +import time +import secrets + +from jose import jwt + +from src.core.config import settings +from src.core.logging_config import get_core_logger + +logger = get_core_logger() + +# JWT lifetime in seconds (Coinbase enforces max 2 minutes) +CDP_JWT_LIFETIME_SECONDS = 120 + +# Coinbase Business API base URL +CDP_API_BASE_URL = "https://api.coinbase.com" + + +def _build_jwt(method: str, path: str) -> str: + """ + Build a signed JWT for a CDP API request. + + Args: + method: HTTP method (GET, POST, etc.) + path: API path (e.g., /api/v1/payment-links) + + Returns: + Signed JWT string + + Raises: + ValueError: If CDP API key credentials are not configured + """ + if not settings.CDP_API_KEY_NAME or not settings.CDP_API_KEY_PRIVATE_KEY: + raise ValueError( + "CDP API credentials not configured. " + "Set CDP_API_KEY_NAME and CDP_API_KEY_PRIVATE_KEY environment variables." + ) + + now = int(time.time()) + uri = f"{method.upper()} api.coinbase.com{path}" + + payload = { + "sub": settings.CDP_API_KEY_NAME, + "iss": "cdp", + "nbf": now, + "exp": now + CDP_JWT_LIFETIME_SECONDS, + "uri": uri, + } + + headers = { + "alg": "ES256", + "typ": "JWT", + "kid": settings.CDP_API_KEY_NAME, + "nonce": secrets.token_hex(16), + } + + # CDP_API_KEY_PRIVATE_KEY is an EC PEM key. + # Environment variables may have literal \n — replace with real newlines. + private_key = settings.CDP_API_KEY_PRIVATE_KEY.replace("\\n", "\n") + + return jwt.encode(payload, private_key, algorithm="ES256", headers=headers) + + +def get_auth_headers(method: str, path: str) -> dict: + """ + Get authentication headers for a CDP API request. + + Args: + method: HTTP method + path: API path + + Returns: + Dict with Authorization header + """ + token = _build_jwt(method, path) + return { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } diff --git a/src/services/coinbase_payment_link_service.py b/src/services/coinbase_payment_link_service.py new file mode 100644 index 00000000..80517bb1 --- /dev/null +++ b/src/services/coinbase_payment_link_service.py @@ -0,0 +1,193 @@ +""" +Coinbase Business Payment Link API service. + +Provides CRUD operations for creating, listing, retrieving, and deactivating +payment links via the Coinbase Business REST API. + +API Reference: https://docs.cdp.coinbase.com/api-reference/business-api/rest-api/payment-links/introduction +""" +from typing import Optional, Dict, Any + +import httpx + +from src.services.coinbase_auth import get_auth_headers, CDP_API_BASE_URL +from src.core.logging_config import get_core_logger + +logger = get_core_logger() + +# Payment Link API base path +PAYMENT_LINKS_PATH = "/api/v1/payment-links" + +# Default timeout for API calls (seconds) +DEFAULT_TIMEOUT = 30.0 + + +class CoinbasePaymentLinkService: + """ + Service for managing Coinbase Business Payment Links. + + Payment links are single-use USDC payment URLs that can be shared + with customers. Once paid, a webhook is fired and the link status + transitions to COMPLETED. + """ + + async def create_payment_link( + self, + amount: str, + currency: str = "USDC", + metadata: Optional[Dict[str, Any]] = None, + description: Optional[str] = None, + success_redirect_url: Optional[str] = None, + failure_redirect_url: Optional[str] = None, + expires_at: Optional[str] = None, + idempotency_key: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Create a new payment link. + + Args: + amount: Payment amount as a string (e.g., "100.00") + currency: Currency code (currently only USDC supported) + metadata: Optional key-value pairs passed through the payment flow + description: Optional description shown on the payment page + success_redirect_url: HTTPS URL to redirect on success + failure_redirect_url: HTTPS URL to redirect on failure + expires_at: ISO 8601 timestamp when the link expires (default: 1 year) + idempotency_key: Optional idempotency key for the request + + Returns: + Payment link response dict with id, url, status, etc. + + Raises: + httpx.HTTPStatusError: On API error responses + ValueError: If CDP credentials are not configured + """ + path = PAYMENT_LINKS_PATH + headers = get_auth_headers("POST", path) + + if idempotency_key: + headers["X-Idempotency-Key"] = idempotency_key + + body: Dict[str, Any] = { + "amount": amount, + "currency": currency, + } + + if metadata: + body["metadata"] = metadata + if description: + body["description"] = description + if success_redirect_url: + body["successRedirectUrl"] = success_redirect_url + if failure_redirect_url: + body["failRedirectUrl"] = failure_redirect_url + if expires_at: + body["expiresAt"] = expires_at + + async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client: + response = await client.post( + f"{CDP_API_BASE_URL}{path}", + headers=headers, + json=body, + ) + response.raise_for_status() + + result = response.json() + logger.info( + "Created Coinbase Payment Link", + payment_link_id=result.get("id"), + payment_link_url=result.get("url"), + amount=amount, + currency=currency, + ) + return result + + async def list_payment_links( + self, + limit: int = 25, + cursor: Optional[str] = None, + status: Optional[str] = None, + ) -> Dict[str, Any]: + """ + List payment links with optional filtering and pagination. + + Args: + limit: Max results per page (default 25) + cursor: Pagination cursor from a previous response + status: Filter by status (ACTIVE, COMPLETED, EXPIRED, DEACTIVATED) + + Returns: + Paginated response with payment links and pagination info + """ + path = PAYMENT_LINKS_PATH + headers = get_auth_headers("GET", path) + + params: Dict[str, Any] = {"limit": limit} + if cursor: + params["cursor"] = cursor + if status: + params["status"] = status + + async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client: + response = await client.get( + f"{CDP_API_BASE_URL}{path}", + headers=headers, + params=params, + ) + response.raise_for_status() + + return response.json() + + async def get_payment_link(self, payment_link_id: str) -> Dict[str, Any]: + """ + Retrieve a single payment link by ID. + + Args: + payment_link_id: The 24-char hex payment link ID + + Returns: + Payment link details + """ + path = f"{PAYMENT_LINKS_PATH}/{payment_link_id}" + headers = get_auth_headers("GET", path) + + async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client: + response = await client.get( + f"{CDP_API_BASE_URL}{path}", + headers=headers, + ) + response.raise_for_status() + + return response.json() + + async def deactivate_payment_link(self, payment_link_id: str) -> Dict[str, Any]: + """ + Deactivate a payment link (prevents further payments). + + Args: + payment_link_id: The 24-char hex payment link ID + + Returns: + Updated payment link with DEACTIVATED status + """ + path = f"{PAYMENT_LINKS_PATH}/{payment_link_id}/deactivate" + headers = get_auth_headers("POST", path) + + async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client: + response = await client.post( + f"{CDP_API_BASE_URL}{path}", + headers=headers, + ) + response.raise_for_status() + + result = response.json() + logger.info( + "Deactivated Coinbase Payment Link", + payment_link_id=payment_link_id, + new_status=result.get("status"), + ) + return result + + +# Singleton instance +coinbase_payment_link_service = CoinbasePaymentLinkService() diff --git a/src/services/coinbase_webhook_service.py b/src/services/coinbase_webhook_service.py index f5c1f1c7..053c5424 100644 --- a/src/services/coinbase_webhook_service.py +++ b/src/services/coinbase_webhook_service.py @@ -1,12 +1,12 @@ """ -Coinbase webhook service for processing payment events. +Coinbase Business webhook service for processing Payment Link payment events. -Supports both: -- NEW: Payment Link API events (payment_link.payment.success/failed/expired) -- LEGACY: Commerce Charge API events (charge:confirmed, etc.) +Handles Payment Link API events: +- payment_link.payment.success: Credits the user's account +- payment_link.payment.failed: Logged for monitoring +- payment_link.payment.expired: Logged for monitoring Docs: https://docs.cdp.coinbase.com/coinbase-business/payment-link-apis/webhooks -Migration: https://docs.cdp.coinbase.com/coinbase-business/payment-link-apis/migrate/overview """ from typing import Optional, Tuple, Dict, Any from decimal import Decimal @@ -24,7 +24,7 @@ class CoinbaseWebhookService: """ - Service for processing Coinbase webhook events. + Service for processing Coinbase Business Payment Link webhook events. All methods are idempotent - processing the same event multiple times will not result in duplicate credits or ledger entries. @@ -32,18 +32,11 @@ class CoinbaseWebhookService: SOURCE_NAME = "coinbase" - # ── Payment Link API Event Types (New) ─────────────────────────────── + # ── Payment Link API Event Types ───────────────────────────────────── EVENT_TYPE_PL_PAYMENT_SUCCESS = "payment_link.payment.success" EVENT_TYPE_PL_PAYMENT_FAILED = "payment_link.payment.failed" EVENT_TYPE_PL_PAYMENT_EXPIRED = "payment_link.payment.expired" - # ── Legacy Commerce Charge API Event Types (Deprecated) ────────────── - EVENT_TYPE_CHARGE_CONFIRMED = "charge:confirmed" - EVENT_TYPE_CHARGE_FAILED = "charge:failed" - EVENT_TYPE_CHARGE_DELAYED = "charge:delayed" - EVENT_TYPE_CHARGE_PENDING = "charge:pending" - EVENT_TYPE_CHARGE_RESOLVED = "charge:resolved" - # Currencies treated as USD-equivalent for crediting purposes # USDC is a stablecoin pegged 1:1 to USD USD_EQUIVALENT_CURRENCIES = {"USD", "USDC"} @@ -196,7 +189,7 @@ async def _create_purchase_entry( return entry # ===================================================================== - # Payment Link API Event Handlers (New) + # Payment Link API Event Handlers # ===================================================================== @staticmethod @@ -415,138 +408,6 @@ async def handle_payment_expired( return True, "Expired payment logged" - # ===================================================================== - # Legacy Commerce Charge API Handlers (Deprecated) - # ===================================================================== - - @staticmethod - def _validate_legacy_charge_amount( - pricing: Dict[str, Any], - log_context: Dict[str, Any], - ) -> Tuple[Optional[Decimal], Optional[str]]: - """ - Validate and extract USD amount from legacy charge pricing data. - - DEPRECATED: Will be removed after migration. - """ - local_pricing = pricing.get("local") or {} - currency = local_pricing.get("currency") - amount_str = local_pricing.get("amount") - - if not currency: - logger.error( - "Missing currency information in legacy Coinbase charge", - **log_context, - ) - return None, "Missing currency information" - - if currency != "USD": - logger.error( - f"Unsupported currency: {currency} (only USD accepted)", - currency=currency, - **log_context, - ) - return None, f"Unsupported currency: {currency}" - - if not amount_str: - return None, "Missing amount information" - - try: - amount_usd = Decimal(amount_str) - except Exception as e: - logger.error( - "Invalid amount format", - amount_str=amount_str, - error=str(e), - **log_context, - ) - return None, "Invalid amount format" - - if amount_usd <= 0: - logger.warning( - "Invalid payment amount (zero or negative)", - amount_usd=str(amount_usd), - **log_context, - ) - return None, "Invalid payment amount" - - return amount_usd, None - - async def handle_charge_confirmed( - self, - db: AsyncSession, - event_data: Dict[str, Any], - event_id: str, - event_type: str, - ) -> Tuple[bool, str]: - """ - Handle a legacy charge:confirmed event (payment received). - - DEPRECATED: Will be removed after migration to Payment Link API. - """ - charge_code = event_data.get("code") - charge_id = event_data.get("id") - transaction_id = charge_code - - log_context = { - "coinbase_charge_code": charge_code, - "coinbase_charge_id": charge_id, - } - - # Check for duplicate - existing = await self._check_idempotency( - db, event_id, event_type, transaction_id, log_context - ) - if existing: - return True, "Already processed" - - # Get user from metadata - metadata = event_data.get("metadata") or {} - user, error = await self._get_user_from_metadata( - db, metadata, log_context=log_context - ) - if error: - return False, error - - # Validate amount - pricing = event_data.get("pricing") or {} - amount_usd, error = self._validate_legacy_charge_amount(pricing, log_context) - if error: - return False, error - - # Build payment metadata - payment_metadata = { - "charge_id": charge_id, - "charge_code": charge_code, - "hosted_url": event_data.get("hosted_url"), - "created_at": event_data.get("created_at"), - "payments": event_data.get("payments", []), - "type": "charge", - } - - # Create entry and update balance - entry = await self._create_purchase_entry( - db=db, - user_id=user.id, - amount_usd=amount_usd, - event_id=event_id, - event_type=event_type, - transaction_id=transaction_id, - payment_metadata=payment_metadata, - description="Coinbase payment - Charge Confirmed (Legacy)", - ) - - logger.info( - "Processed legacy Coinbase charge:confirmed", - event_type=self.EVENT_TYPE_CHARGE_CONFIRMED, - user_id=user.id, - amount_usd=str(amount_usd), - ledger_entry_id=str(entry.id), - **log_context, - ) - - return True, "Credits added successfully" - # Singleton instance coinbase_webhook_service = CoinbaseWebhookService() From 4a91bec1dea8ae40ddef6a19b875cbe0101b05c4 Mon Sep 17 00:00:00 2001 From: BowTiedSwan <86532747+BowTiedSwan@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:29:08 +0200 Subject: [PATCH 2/3] Update coinbase-business-migration-backend-review.md --- docs/coinbase-business-migration-backend-review.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/coinbase-business-migration-backend-review.md b/docs/coinbase-business-migration-backend-review.md index 3048e8a5..ace3142f 100644 --- a/docs/coinbase-business-migration-backend-review.md +++ b/docs/coinbase-business-migration-backend-review.md @@ -8,7 +8,6 @@ ## 1. Coinbase Dashboard / Account Setup - [ ] **Create Coinbase Business account** (or convert existing Commerce account) - - Currently available in **US and Singapore only** - [Getting Started](https://docs.cdp.coinbase.com/coinbase-business/introduction/get-started) - [ ] **Complete KYB (Know Your Business) verification** if not already done - [ ] **Generate CDP API Key** in the [CDP Portal](https://portal.cdp.coinbase.com/access/api) From 8a3de239c854c2d38c03f50c576791ee358e230c Mon Sep 17 00:00:00 2001 From: Aleksandr Kukharenko Date: Tue, 24 Mar 2026 16:33:28 +0200 Subject: [PATCH 3/3] feat: update api --- docker-compose.local.yml | 6 + ...nbase-business-migration-backend-review.md | 194 +++++++++++++---- env.example | 16 +- pyproject.toml | 7 +- src/api/v1/__init__.py | 2 + src/api/v1/billing/admin.py | 104 +-------- src/api/v1/billing/coinbase.py | 105 +++++++++ src/api/v1/webhooks/coinbase.py | 204 ++++++++++++++++-- src/core/config.py | 17 +- src/schemas/payment_link.py | 7 +- src/services/coinbase_auth.py | 64 +++--- src/services/coinbase_payment_link_service.py | 20 +- src/services/coinbase_webhook_service.py | 150 ++++++++++++- 13 files changed, 673 insertions(+), 223 deletions(-) create mode 100644 src/api/v1/billing/coinbase.py diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 0d66cada..649e5c2f 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -72,6 +72,12 @@ services: STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY} STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET} + # Coinbase Business / CDP (new Payment Link API) + CDP_API_KEY_ID: ${CDP_API_KEY_ID:-} + CDP_API_KEY_SECRET: ${CDP_API_KEY_SECRET:-} + CDP_SANDBOX: ${CDP_SANDBOX:-true} + COINBASE_PAYMENT_LINK_WEBHOOK_SECRET: ${COINBASE_PAYMENT_LINK_WEBHOOK_SECRET:-} + LOCAL_TESTING_MODE: ${LOCAL_TESTING_MODE:-false} # Cognito authentication (ensure these are passed from .env.local) diff --git a/docs/coinbase-business-migration-backend-review.md b/docs/coinbase-business-migration-backend-review.md index ace3142f..29a1fbf2 100644 --- a/docs/coinbase-business-migration-backend-review.md +++ b/docs/coinbase-business-migration-backend-review.md @@ -10,11 +10,12 @@ - [ ] **Create Coinbase Business account** (or convert existing Commerce account) - [Getting Started](https://docs.cdp.coinbase.com/coinbase-business/introduction/get-started) - [ ] **Complete KYB (Know Your Business) verification** if not already done -- [ ] **Generate CDP API Key** in the [CDP Portal](https://portal.cdp.coinbase.com/access/api) - - Select **ES256** algorithm - - Enable **View** scope (covers Payment Link CRUD) - - Download the private key PEM file — it's shown only once - - Note the key name format: `organizations/{org_id}/apiKeys/{key_id}` +- [ ] **Generate CDP Secret API Key** in the [CDP Portal](https://portal.cdp.coinbase.com/projects/api-keys) + - Go to the **Secret API Keys** tab and click **Create API key** + - Signature algorithm: Ed25519 (recommended) or ECDSA + - Save the **Key ID** (UUID) → `CDP_API_KEY_ID` + - Save the **Key Secret** (base64 string) → `CDP_API_KEY_SECRET` + - These are shown only once --- @@ -22,15 +23,18 @@ | Variable | Description | Where | |---|---|---| -| `CDP_API_KEY_NAME` | Key name from CDP portal (`organizations/{org_id}/apiKeys/{key_id}`) | All environments | -| `CDP_API_KEY_PRIVATE_KEY` | EC private key PEM (newlines as `\n`) | All environments (secrets manager) | +| `CDP_API_KEY_ID` | Secret API Key ID (UUID) from [CDP Portal](https://portal.cdp.coinbase.com/projects/api-keys) | All environments | +| `CDP_API_KEY_SECRET` | Secret API Key secret (base64) from CDP Portal | All environments (secrets manager) | +| `CDP_SANDBOX` | `true` for sandbox (no real transactions), `false` for production | Per environment | | `COINBASE_PAYMENT_LINK_WEBHOOK_SECRET` | From webhook subscription metadata | All environments | -### Variables to Remove +### Variables to Keep (Legacy - Transition Period) | Variable | Reason | |---|---| -| `COINBASE_COMMERCE_WEBHOOK_SECRET` | Legacy Commerce API — no longer used | +| `COINBASE_COMMERCE_WEBHOOK_SECRET` | Legacy Commerce API — **kept during transition period** for backward compatibility | + +> **Note:** Legacy Commerce variables will be removed after migration is confirmed complete and all in-flight Commerce charges have settled. --- @@ -52,8 +56,10 @@ - Format: `t=,h=,v1=` - Replay protection: Rejects events older than 5 minutes -### Old Format (removed) -- Header: `X-CC-Webhook-Signature` — no longer accepted by our endpoint +### Old Format (still supported during transition) +- Header: `X-CC-Webhook-Signature` — legacy Commerce format +- The webhook endpoint auto-detects the format based on which signature header is present +- Both formats are supported simultaneously via `_detect_webhook_format()` --- @@ -67,16 +73,14 @@ X-CC-Version: 2018-03-22 ### New (CDP / Business) ``` -Authorization: Bearer +Authorization: Bearer Content-Type: application/json ``` -The JWT is generated **per request** with: -- `sub`: CDP key name -- `iss`: `"cdp"` -- `uri`: `"{METHOD} api.coinbase.com{PATH}"` -- `exp`: current time + 120 seconds -- `nonce`: random hex +JWT generation is handled by the `cdp-sdk` Python package using: +- `CDP_API_KEY_ID` (UUID) and `CDP_API_KEY_SECRET` (base64) +- Supports both Ed25519 and ECDSA key types (SDK auto-detects) +- See: https://docs.cdp.coinbase.com/api-reference/v2/authentication Implementation: `src/services/coinbase_auth.py` @@ -84,14 +88,38 @@ Implementation: `src/services/coinbase_auth.py` ## 5. Payment Link API Endpoints -Base URL: `https://api.coinbase.com` +### Coinbase Business API (upstream) -| Operation | Method | Path | -|---|---|---| -| Create | `POST` | `/api/v1/payment-links` | -| List | `GET` | `/api/v1/payment-links` | -| Get | `GET` | `/api/v1/payment-links/{id}` | -| Deactivate | `POST` | `/api/v1/payment-links/{id}/deactivate` | +Base URL: `https://business.coinbase.com` + +| Operation | Method | Production Path | Sandbox Path | +|---|---|---|---| +| Create | `POST` | `/api/v1/payment-links` | `/sandbox/api/v1/payment-links` | +| List | `GET` | `/api/v1/payment-links` | `/sandbox/api/v1/payment-links` | +| Get | `GET` | `/api/v1/payment-links/{id}` | `/sandbox/api/v1/payment-links/{id}` | +| Deactivate | `POST` | `/api/v1/payment-links/{id}/deactivate` | `/sandbox/api/v1/payment-links/{id}/deactivate` | + +Controlled by `CDP_SANDBOX` env var. See [Sandbox docs](https://docs.cdp.coinbase.com/coinbase-business/payment-link-apis/sandbox). + +### Our API Endpoints + +**User-facing** (under `/api/v1/billing/coinbase/`): + +| Operation | Method | Path | Auth | +|---|---|---|---| +| Create Payment Link | `POST` | `/api/v1/billing/coinbase/payment-links` | User (Cognito JWT) | +| Get Payment Link | `GET` | `/api/v1/billing/coinbase/payment-links/{id}` | User (Cognito JWT) | + +Implementation: `src/api/v1/billing/coinbase.py` + +**Admin-only** (under `/api/v1/billing/`, requires `X-Admin-Secret`): + +| Operation | Method | Path | Auth | +|---|---|---|---| +| List Payment Links | `GET` | `/api/v1/billing/payment-links` | Admin (X-Admin-Secret) | +| Deactivate Payment Link | `POST` | `/api/v1/billing/payment-links/{id}/deactivate` | Admin (X-Admin-Secret) | + +Implementation: `src/api/v1/billing/admin.py` ### Key Differences from Commerce Charges @@ -132,14 +160,86 @@ No schema changes required — the `credits_ledger` table already supports both --- -## 8. Testing Checklist +## 8. User Identification in Payment Flow + +The `metadata` field on the payment link is used to pass the user identifier through the Coinbase payment flow. Coinbase treats metadata as an opaque key-value store and returns it verbatim in webhook payloads. + +### Flow + +1. **Create** (`POST /api/v1/billing/coinbase/payment-links`): + The authenticated user's `cognito_user_id` is **automatically injected** into `metadata.user_id` server-side. The caller cannot override this — it is set from the JWT-authenticated session. + + ```json + // Sent to Coinbase API: + { + "amount": "10.00", + "currency": "USDC", + "metadata": { + "user_id": "" + } + } + ``` + +2. **Webhook** (`POST /api/v1/webhooks/coinbase`): + Coinbase sends the `metadata` back in the event payload. The webhook handler reads `metadata.user_id` to look up the user and credit their account. + + ```json + // Received from Coinbase: + { + "id": "69163c762331ed43dc64a6ef", + "eventType": "payment_link.payment.success", + "amount": "10.00", + "currency": "USDC", + "metadata": { + "user_id": "" + }, + ... + } + ``` + +3. **Credit**: The webhook service looks up the user by `cognito_user_id`, validates the amount, and creates a purchase ledger entry. + +### Implementation +- Injection: `src/api/v1/billing/coinbase.py` → `metadata["user_id"] = current_user.cognito_user_id` +- Extraction: `src/services/coinbase_webhook_service.py` → `_get_user_from_metadata()` + +--- + +## 9. Transition Architecture (Dual Webhook Support) + +During the transition period, the system supports **both** Commerce and Payment Link webhooks simultaneously: + +``` +POST /api/v1/webhooks/coinbase + ├── X-Hook0-Signature header present → Payment Link handler (new) + └── X-CC-Webhook-Signature header present → Legacy Commerce handler (deprecated) +``` + +### Files involved: +- `src/api/v1/webhooks/coinbase.py` — Dual-format webhook endpoint with auto-detection +- `src/services/coinbase_webhook_service.py` — Event handlers for both formats +- `src/api/v1/billing/coinbase.py` — New Payment Link CRUD endpoints (user-facing) +- `src/services/coinbase_payment_link_service.py` — Payment Link API client +- `src/services/coinbase_auth.py` — CDP JWT auth for API calls +- `src/core/config.py` — Both `COINBASE_COMMERCE_WEBHOOK_SECRET` and `COINBASE_PAYMENT_LINK_WEBHOOK_SECRET` + +--- + +## 10. Testing Checklist + +### Sandbox Testing +- [ ] Set `CDP_SANDBOX=true` and verify API calls go to `/sandbox/api/v1/payment-links` +- [ ] Create a sandbox payment link and complete payment with [testnet USDC](https://portal.cdp.coinbase.com/products/faucet) +- [ ] Register a sandbox webhook subscription (with `"sandbox": "true"` label) +- [ ] Verify sandbox webhook events are received and processed correctly ### Pre-deployment - [ ] Verify JWT signing works with test CDP key -- [ ] Create a test payment link via admin endpoint -- [ ] Verify webhook signature verification with test payload +- [ ] Create a test payment link via `POST /api/v1/billing/coinbase/payment-links` +- [ ] Verify webhook signature verification with test payload (both formats) - [ ] Test idempotency (same webhook delivered twice) - [ ] Test expired/failed webhook handling +- [ ] Verify legacy Commerce webhooks still work during transition ### Post-deployment - [ ] Create a real payment link and complete payment @@ -153,38 +253,58 @@ Coinbase provides a [Postman collection](https://docs.cdp.coinbase.com/coinbase- --- -## 9. Rollback Plan +## 11. Rollback Plan If issues are discovered after deployment: -1. The webhook endpoint only accepts `X-Hook0-Signature` — if you need to revert, restore the legacy `verify_legacy_commerce_signature` function from git history -2. CDP API key credentials can coexist with Commerce API keys during transition -3. The `COINBASE_COMMERCE_WEBHOOK_SECRET` config was removed from code but the env var can remain set harmlessly +1. The webhook endpoint supports both `X-Hook0-Signature` and `X-CC-Webhook-Signature` — legacy Commerce continues to work without code changes +2. CDP API key credentials coexist with Commerce API keys during transition +3. Both `COINBASE_COMMERCE_WEBHOOK_SECRET` and `COINBASE_PAYMENT_LINK_WEBHOOK_SECRET` can be set simultaneously --- -## 10. IP Allowlisting +## 12. Post-Migration Cleanup (after transition) + +Once all Commerce charges have settled and new system is confirmed working: + +- [ ] Remove `COINBASE_COMMERCE_WEBHOOK_SECRET` from config +- [ ] Remove `_detect_webhook_format()` and `verify_legacy_commerce_signature()` from webhook handler +- [ ] Remove `_handle_legacy_commerce_webhook()` from webhook handler +- [ ] Remove legacy event types and `handle_charge_confirmed()` from webhook service +- [ ] Update webhook endpoint to only accept `X-Hook0-Signature` + +--- + +## 13. IP Allowlisting - [ ] If using CDP API key IP allowlisting, ensure all API server IPs are added - [ ] If behind a load balancer, verify the outbound IP (NAT gateway) is allowlisted --- -## 11. Monitoring & Alerting +## 14. Monitoring & Alerting Ensure alerts are configured for these log event types: + +**Payment Link (new):** - `coinbase_pl_webhook_not_configured` — Secret missing (critical) - `coinbase_pl_webhook_invalid_signature` — Signature mismatch (security) - `coinbase_pl_webhook_replay` — Replay attack attempt (security) -- `admin_payment_link_error` — API call failures (operational) +- `coinbase_payment_link_error` — API call failures (operational) + +**Legacy Commerce (transition period):** +- `coinbase_legacy_webhook_not_configured` — Legacy secret missing +- `coinbase_legacy_webhook_invalid_signature` — Legacy signature mismatch --- -## 12. Documentation References +## 15. Documentation References - [Migration Overview](https://docs.cdp.coinbase.com/coinbase-business/payment-link-apis/migrate/overview) - [API & Schema Mapping](https://docs.cdp.coinbase.com/coinbase-business/payment-link-apis/migrate/api-schema-mapping) - [Payment Link API Reference](https://docs.cdp.coinbase.com/api-reference/business-api/rest-api/payment-links/introduction) -- [CDP API Key Auth](https://docs.cdp.coinbase.com/coinbase-business/authentication-authorization/api-key-authentication) +- [CDP API Key Auth](https://docs.cdp.coinbase.com/api-reference/v2/authentication) - [Webhook Docs](https://docs.cdp.coinbase.com/coinbase-business/payment-link-apis/webhooks) +- [Sandbox Environment](https://docs.cdp.coinbase.com/coinbase-business/payment-link-apis/sandbox) +- [Testnet Faucet (Base Sepolia USDC)](https://portal.cdp.coinbase.com/products/faucet) - [Migration FAQ](https://docs.cdp.coinbase.com/coinbase-business/payment-link-apis/migrate/faq) diff --git a/env.example b/env.example index 22e0b3be..e4e60d62 100644 --- a/env.example +++ b/env.example @@ -160,12 +160,16 @@ STRIPE_WEBHOOK_SECRET=whsec_your_webhook_signing_secret # ============================================================================= # COINBASE BUSINESS CONFIGURATION # ============================================================================= -# CDP API Key for Payment Link CRUD operations -# Generate at: https://portal.cdp.coinbase.com/access/api -# Key name format: organizations/{org_id}/apiKeys/{key_id} -CDP_API_KEY_NAME=organizations/your-org-id/apiKeys/your-key-id -# EC private key in PEM format (replace newlines with \n) -CDP_API_KEY_PRIVATE_KEY="-----BEGIN EC PRIVATE KEY-----\nYOUR_KEY_HERE\n-----END EC PRIVATE KEY-----" +# CDP Secret API Key for Payment Link CRUD operations +# Generate at: https://portal.cdp.coinbase.com/projects/api-keys (Secret API Keys tab) +# Docs: https://docs.cdp.coinbase.com/api-reference/v2/authentication +# Key ID: UUID from the CDP portal +CDP_API_KEY_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +# Key Secret: base64-encoded secret from the CDP portal +CDP_API_KEY_SECRET=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX== +# Set to true for sandbox (no real transactions, uses Base Sepolia testnet) +# See: https://docs.cdp.coinbase.com/coinbase-business/payment-link-apis/sandbox +CDP_SANDBOX=false # Payment Link webhook signature verification secret # From metadata.secret when creating a webhook subscription diff --git a/pyproject.toml b/pyproject.toml index b774ea0a..afccaa23 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,10 +26,11 @@ boto3 = "^1.34.0" structlog = "^23.2.0" python-multipart = "0.0.20" stripe = "^11.0.0" -web3 = "^6.0.0" -eth-account = "^0.10.0" +cdp-sdk = "^1.4.0" +web3 = "^7.0.0" +eth-account = "^0.13.0" redis = "^5.0.0" -siwe = "^4.0.0" +siwe = "^4.4.0" [tool.poetry.group.dev.dependencies] pytest = "^7.4.0" diff --git a/src/api/v1/__init__.py b/src/api/v1/__init__.py index 92549a55..9e9e84d3 100644 --- a/src/api/v1/__init__.py +++ b/src/api/v1/__init__.py @@ -9,6 +9,7 @@ from .audio.index import router as audio_router from .billing.index import router as billing_router from .billing.admin import admin_router as billing_admin_router +from .billing.coinbase import coinbase_billing_router from .webhooks.stripe import stripe_webhook_router from .webhooks.coinbase import coinbase_webhook_router from .wallet.index import router as wallet_router @@ -37,6 +38,7 @@ # Billing router billing = APIRouter() billing.include_router(billing_router) +billing.include_router(coinbase_billing_router) # Billing admin router (separate Swagger page at /admin/docs) billing_admin = APIRouter() diff --git a/src/api/v1/billing/admin.py b/src/api/v1/billing/admin.py index c065a239..df329067 100644 --- a/src/api/v1/billing/admin.py +++ b/src/api/v1/billing/admin.py @@ -25,7 +25,6 @@ RateLimitMultiplierResponse, ) from ....schemas.payment_link import ( - CreatePaymentLinkRequest, PaymentLinkResponse, PaymentLinkListResponse, ) @@ -415,71 +414,13 @@ async def get_rate_limit_multiplier( ) -# === Coinbase Payment Link Endpoints === - - -@admin_router.post("/payment-links", response_model=PaymentLinkResponse, tags=["Payment Links"]) -async def create_payment_link( - request: CreatePaymentLinkRequest, - current_user: User = Depends(get_current_user), - _admin_verified: bool = Depends(verify_billing_admin_secret), -): - """ - Create a Coinbase Business Payment Link. - - **Admin endpoint** - Requires X-Admin-Secret header. - - Creates a USDC payment link via the Coinbase Business API. - The user_id of the authenticated user is automatically added to metadata - so the webhook can credit the correct account. - """ - try: - # Inject user_id into metadata for webhook correlation - metadata = request.metadata or {} - metadata["user_id"] = current_user.cognito_user_id - - result = await coinbase_payment_link_service.create_payment_link( - amount=request.amount, - currency=request.currency, - metadata=metadata, - description=request.description, - success_redirect_url=request.success_redirect_url, - failure_redirect_url=request.failure_redirect_url, - expires_at=request.expires_at, - ) - - logger.info( - "Payment link created via admin API", - user_id=current_user.id, - payment_link_id=result.get("id"), - amount=request.amount, - currency=request.currency, - event_type="admin_payment_link_created", - ) - - return result - except ValueError as e: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail=str(e), - ) - except Exception as e: - logger.error( - "Error creating payment link", - error=str(e), - error_type=type(e).__name__, - event_type="admin_payment_link_error", - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error creating payment link: {str(e)}", - ) +# === Coinbase Payment Link Admin Endpoints === @admin_router.get("/payment-links", response_model=PaymentLinkListResponse, tags=["Payment Links"]) async def list_payment_links( - limit: int = Query(default=25, ge=1, le=100, description="Max results per page"), - cursor: Optional[str] = Query(default=None, description="Pagination cursor"), + page_size: int = Query(default=20, ge=1, le=100, alias="pageSize", description="Max results per page"), + page_token: Optional[str] = Query(default=None, alias="pageToken", description="Pagination token from previous response"), link_status: Optional[str] = Query(default=None, alias="status", description="Filter by status"), current_user: User = Depends(get_current_user), _admin_verified: bool = Depends(verify_billing_admin_secret), @@ -490,12 +431,12 @@ async def list_payment_links( **Admin endpoint** - Requires X-Admin-Secret header. Returns a paginated list of payment links with optional status filtering. - Status values: ACTIVE, COMPLETED, EXPIRED, DEACTIVATED. + Status values: ACTIVE, PROCESSING, COMPLETED, EXPIRED, DEACTIVATED, FAILED. """ try: result = await coinbase_payment_link_service.list_payment_links( - limit=limit, - cursor=cursor, + page_size=page_size, + page_token=page_token, status=link_status, ) return result @@ -517,39 +458,6 @@ async def list_payment_links( ) -@admin_router.get("/payment-links/{payment_link_id}", response_model=PaymentLinkResponse, tags=["Payment Links"]) -async def get_payment_link( - payment_link_id: str, - current_user: User = Depends(get_current_user), - _admin_verified: bool = Depends(verify_billing_admin_secret), -): - """ - Get a specific Coinbase Business Payment Link by ID. - - **Admin endpoint** - Requires X-Admin-Secret header. - """ - try: - result = await coinbase_payment_link_service.get_payment_link(payment_link_id) - return result - except ValueError as e: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail=str(e), - ) - except Exception as e: - logger.error( - "Error getting payment link", - payment_link_id=payment_link_id, - error=str(e), - error_type=type(e).__name__, - event_type="admin_payment_link_get_error", - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error getting payment link: {str(e)}", - ) - - @admin_router.post("/payment-links/{payment_link_id}/deactivate", response_model=PaymentLinkResponse, tags=["Payment Links"]) async def deactivate_payment_link( payment_link_id: str, diff --git a/src/api/v1/billing/coinbase.py b/src/api/v1/billing/coinbase.py new file mode 100644 index 00000000..6c16fdf7 --- /dev/null +++ b/src/api/v1/billing/coinbase.py @@ -0,0 +1,105 @@ +""" +Coinbase Business Payment Link billing endpoints. +Authenticated user endpoints for creating payment links and checking status. +""" +from fastapi import APIRouter, Depends, HTTPException, status + +from ....db.models import User +from ....dependencies import get_current_user +from ....schemas.payment_link import ( + CreatePaymentLinkRequest, + PaymentLinkResponse, +) +from ....core.logging_config import get_api_logger +from ....services.coinbase_payment_link_service import coinbase_payment_link_service + +logger = get_api_logger() + +router = APIRouter(prefix="/coinbase", tags=["Coinbase Billing"]) + + +@router.post("/payment-links", response_model=PaymentLinkResponse) +async def create_payment_link( + request: CreatePaymentLinkRequest, + current_user: User = Depends(get_current_user), +): + """ + Create a Coinbase Business Payment Link for the authenticated user. + + Creates a USDC payment link via the Coinbase Business API. + The user_id is automatically added to metadata so the webhook + can credit the correct account. + """ + try: + metadata = request.metadata or {} + metadata["user_id"] = current_user.cognito_user_id + + result = await coinbase_payment_link_service.create_payment_link( + amount=request.amount, + currency=request.currency, + metadata=metadata, + description=request.description, + success_redirect_url=request.success_redirect_url, + failure_redirect_url=request.failure_redirect_url, + expires_at=request.expires_at, + ) + + logger.info( + "Payment link created", + user_id=current_user.id, + payment_link_id=result.get("id"), + amount=request.amount, + currency=request.currency, + event_type="coinbase_payment_link_created", + ) + + return result + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=str(e), + ) + except Exception as e: + logger.error( + "Error creating payment link", + error=str(e), + error_type=type(e).__name__, + event_type="coinbase_payment_link_error", + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error creating payment link: {str(e)}", + ) + + +@router.get("/payment-links/{payment_link_id}", response_model=PaymentLinkResponse) +async def get_payment_link( + payment_link_id: str, + current_user: User = Depends(get_current_user), +): + """ + Get a specific Coinbase Business Payment Link by ID. + """ + try: + result = await coinbase_payment_link_service.get_payment_link(payment_link_id) + return result + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=str(e), + ) + except Exception as e: + logger.error( + "Error getting payment link", + payment_link_id=payment_link_id, + error=str(e), + error_type=type(e).__name__, + event_type="coinbase_payment_link_get_error", + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error getting payment link: {str(e)}", + ) + + +coinbase_billing_router = router diff --git a/src/api/v1/webhooks/coinbase.py b/src/api/v1/webhooks/coinbase.py index 8e5ca084..5ad37d2e 100644 --- a/src/api/v1/webhooks/coinbase.py +++ b/src/api/v1/webhooks/coinbase.py @@ -1,13 +1,14 @@ """ -Coinbase Business webhook endpoint for receiving Payment Link payment events. +Coinbase webhook endpoint for receiving payment events. -Supports Payment Link API webhooks: -- payment_link.payment.success: Payment completed -- payment_link.payment.failed: Payment failed -- payment_link.payment.expired: Payment link expired +Supports both: +- NEW: Payment Link API webhooks (payment_link.payment.success/failed/expired) + Signature: X-Hook0-Signature header (t=timestamp,h=headers,v1=hmac) + Docs: https://docs.cdp.coinbase.com/coinbase-business/payment-link-apis/webhooks -Signature: X-Hook0-Signature header (t=timestamp,h=headers,v1=hmac) -Docs: https://docs.cdp.coinbase.com/coinbase-business/payment-link-apis/webhooks +- LEGACY: Commerce Charge API webhooks (charge:confirmed, etc.) + Signature: X-CC-Webhook-Signature header (HMAC-SHA256 of body) + Will be removed after migration is complete. """ import hmac import hashlib @@ -32,6 +33,21 @@ # === Signature Verification === +def _detect_webhook_format(request: Request) -> str: + """ + Detect whether the incoming webhook uses the new Payment Link format + or legacy Commerce Charge format based on the signature header. + + Returns: + "payment_link" or "legacy_commerce" + """ + if request.headers.get("x-hook0-signature"): + return "payment_link" + if request.headers.get("x-cc-webhook-signature"): + return "legacy_commerce" + return "unknown" + + async def verify_payment_link_signature( request: Request, body_bytes: bytes ) -> None: @@ -150,7 +166,67 @@ async def verify_payment_link_signature( ) -# === Payment Link API Webhook Handler === +async def verify_legacy_commerce_signature( + request: Request, body_bytes: bytes +) -> None: + """ + Verify the legacy Commerce Charge API webhook signature (X-CC-Webhook-Signature). + + DEPRECATED: This will be removed after migration to Payment Link API is complete. + + Raises: + HTTPException: If verification fails + """ + if not settings.COINBASE_COMMERCE_WEBHOOK_SECRET: + logger.error( + "Legacy Commerce webhook received but COINBASE_COMMERCE_WEBHOOK_SECRET not configured", + event_type="coinbase_legacy_webhook_not_configured", + ) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Coinbase Commerce webhook not configured", + ) + + sig_header = request.headers.get("x-cc-webhook-signature") + if not sig_header: + logger.warning( + "Legacy Commerce webhook missing signature header", + event_type="coinbase_legacy_webhook_missing_signature", + ) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Missing X-CC-Webhook-Signature header", + ) + + try: + signature = hmac.new( + settings.COINBASE_COMMERCE_WEBHOOK_SECRET.encode("utf-8"), + body_bytes, + digestmod=hashlib.sha256, + ).hexdigest() + except Exception as e: + logger.error( + "Error computing legacy Commerce signature", + error=str(e), + event_type="coinbase_legacy_webhook_verification_error", + ) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Signature verification error", + ) + + if not hmac.compare_digest(signature, sig_header): + logger.warning( + "Legacy Commerce webhook signature verification failed", + event_type="coinbase_legacy_webhook_invalid_signature", + ) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid signature", + ) + + +# === Payment Link API Webhook Handler (New) === async def _handle_payment_link_webhook( @@ -243,6 +319,82 @@ async def _handle_payment_link_webhook( return {"received": True} +# === Legacy Commerce Webhook Handler === + + +async def _handle_legacy_commerce_webhook( + payload: dict, + db: AsyncSession, +) -> dict: + """ + Handle a legacy Commerce Charge API webhook event. + + DEPRECATED: Will be removed after migration to Payment Link API is complete. + + Legacy payloads wrap events: + { "id": "delivery-id", "event": { "id": "...", "type": "charge:confirmed", "data": {...} } } + """ + event = payload.get("event") + if not event or not isinstance(event, dict): + logger.warning( + "Invalid legacy Commerce webhook payload (missing 'event' key)", + payload_keys=list(payload.keys()) if isinstance(payload, dict) else None, + event_type="coinbase_legacy_webhook_invalid_format", + ) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid event format: missing 'event' key", + ) + + event_id = event.get("id") + event_type = event.get("type") + + if not event_id or not event_type: + logger.warning( + "Invalid legacy Commerce event format (missing id or type)", + event_keys=list(event.keys()), + event_type="coinbase_legacy_webhook_invalid_format", + ) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid event format: missing id or type", + ) + + logger.info( + "Received legacy Commerce webhook event (consider migrating to Payment Link API)", + coinbase_event_id=event_id, + coinbase_event_type=event_type, + ) + + event_data = event.get("data", {}) + + if event_type == coinbase_webhook_service.EVENT_TYPE_CHARGE_CONFIRMED: + success, message = await coinbase_webhook_service.handle_charge_confirmed( + db=db, + event_data=event_data, + event_id=event_id, + event_type=event_type, + ) + if not success: + logger.error( + "Failed to process charge:confirmed", + coinbase_event_id=event_id, + error=message, + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to process event: {message}", + ) + else: + logger.info( + "Received unhandled legacy Commerce event type", + coinbase_event_id=event_id, + coinbase_event_type=event_type, + ) + + return {"received": True} + + # === Main Endpoint === @@ -252,14 +404,20 @@ async def handle_coinbase_webhook( db: AsyncSession = Depends(get_db_session), ): """ - Handle incoming Coinbase Business Payment Link webhook events. + Handle incoming Coinbase webhook events. - Verifies the X-Hook0-Signature header and routes to the appropriate handler. + Supports both Payment Link API (new) and legacy Commerce Charge API formats. + Auto-detects the format based on the signature header: + - X-Hook0-Signature → Payment Link API (payment_link.payment.*) + - X-CC-Webhook-Signature → Legacy Commerce (charge:*) Payment Link event types: - payment_link.payment.success: Payment completed successfully - payment_link.payment.failed: Payment failed - payment_link.payment.expired: Payment link expired + + Legacy event types (deprecated): + - charge:confirmed: Payment confirmed """ # Read body once for verification and parsing try: @@ -275,8 +433,22 @@ async def handle_coinbase_webhook( detail="Invalid request body", ) - # Verify Payment Link signature - await verify_payment_link_signature(request, body_bytes) + # Detect webhook format and verify signature + webhook_format = _detect_webhook_format(request) + + if webhook_format == "payment_link": + await verify_payment_link_signature(request, body_bytes) + elif webhook_format == "legacy_commerce": + await verify_legacy_commerce_signature(request, body_bytes) + else: + logger.warning( + "Coinbase webhook missing both X-Hook0-Signature and X-CC-Webhook-Signature headers", + event_type="coinbase_webhook_no_signature", + ) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Missing webhook signature header", + ) # Parse JSON body try: @@ -292,15 +464,19 @@ async def handle_coinbase_webhook( detail="Invalid JSON body", ) - # Handle event + # Route to the appropriate handler try: - return await _handle_payment_link_webhook(payload, db) + if webhook_format == "payment_link": + return await _handle_payment_link_webhook(payload, db) + else: + return await _handle_legacy_commerce_webhook(payload, db) except HTTPException: raise except Exception as e: logger.exception( "Error processing Coinbase webhook event", + webhook_format=webhook_format, error=str(e), ) raise HTTPException( diff --git a/src/core/config.py b/src/core/config.py index 12d6f83b..48674c9d 100644 --- a/src/core/config.py +++ b/src/core/config.py @@ -222,6 +222,10 @@ def assemble_cors_origins(cls, v: Union[str, List[str]]) -> Union[List[str], str STRIPE_SECRET_KEY: str | None = Field(default=os.getenv("STRIPE_SECRET_KEY")) STRIPE_WEBHOOK_SECRET: str | None = Field(default=os.getenv("STRIPE_WEBHOOK_SECRET")) + # Coinbase Commerce Settings (Legacy - kept for backward compatibility) + # Required for processing legacy Coinbase Commerce charge webhooks + COINBASE_COMMERCE_WEBHOOK_SECRET: str | None = Field(default=os.getenv("COINBASE_COMMERCE_WEBHOOK_SECRET")) + # Coinbase Business / CDP Settings # Payment Link webhook signature verification secret # Secret from metadata.secret returned when creating a webhook subscription @@ -229,11 +233,14 @@ def assemble_cors_origins(cls, v: Union[str, List[str]]) -> Union[List[str], str COINBASE_PAYMENT_LINK_WEBHOOK_SECRET: str | None = Field(default=os.getenv("COINBASE_PAYMENT_LINK_WEBHOOK_SECRET")) # CDP API Key credentials for Payment Link CRUD operations - # Key name format: organizations/{org_id}/apiKeys/{key_id} - # See: https://docs.cdp.coinbase.com/coinbase-business/authentication-authorization/api-key-authentication - CDP_API_KEY_NAME: str | None = Field(default=os.getenv("CDP_API_KEY_NAME")) - # EC private key in PEM format (use literal \n for newlines in env vars) - CDP_API_KEY_PRIVATE_KEY: str | None = Field(default=os.getenv("CDP_API_KEY_PRIVATE_KEY")) + # Key ID: UUID from the CDP portal (Secret API Key tab) + # Key Secret: base64-encoded secret from the CDP portal + # See: https://docs.cdp.coinbase.com/api-reference/v2/authentication + CDP_API_KEY_ID: str | None = Field(default=os.getenv("CDP_API_KEY_ID")) + CDP_API_KEY_SECRET: str | None = Field(default=os.getenv("CDP_API_KEY_SECRET")) + # Set to true to use the Coinbase sandbox environment (no real transactions) + # See: https://docs.cdp.coinbase.com/coinbase-business/payment-link-apis/sandbox + CDP_SANDBOX: bool = Field(default=os.getenv("CDP_SANDBOX", "false").lower() == "true") # Web3 Provider Settings (optional - enables EIP-1271 smart contract wallet verification) # If not set, only EOA wallets will be supported diff --git a/src/schemas/payment_link.py b/src/schemas/payment_link.py index 8bc51e86..7ce9f538 100644 --- a/src/schemas/payment_link.py +++ b/src/schemas/payment_link.py @@ -34,6 +34,7 @@ class PaymentLinkResponse(BaseModel): class PaymentLinkListResponse(BaseModel): """Paginated list of payment links.""" - data: List[Dict[str, Any]] = Field(default_factory=list) - has_more: Optional[bool] = None - cursor: Optional[str] = None + payment_links: List[Dict[str, Any]] = Field(default_factory=list, alias="paymentLinks") + next_page_token: Optional[str] = Field(None, alias="nextPageToken") + + model_config = {"populate_by_name": True} diff --git a/src/services/coinbase_auth.py b/src/services/coinbase_auth.py index b03da349..72a5b453 100644 --- a/src/services/coinbase_auth.py +++ b/src/services/coinbase_auth.py @@ -1,35 +1,36 @@ """ CDP (Coinbase Developer Platform) API Key authentication. -Generates ES256-signed JWTs for authenticating with the Coinbase Business API. -Each request requires a unique JWT with the target URI embedded in the payload. +Uses the CDP SDK to generate signed JWTs for authenticating with the +Coinbase Business API. Each request requires a unique JWT with the +target URI embedded in the payload. -Docs: https://docs.cdp.coinbase.com/coinbase-business/authentication-authorization/api-key-authentication +Docs: https://docs.cdp.coinbase.com/api-reference/v2/authentication +Sandbox: https://docs.cdp.coinbase.com/coinbase-business/payment-link-apis/sandbox """ -import time -import secrets - -from jose import jwt +from cdp.auth.utils.jwt import generate_jwt, JwtOptions from src.core.config import settings from src.core.logging_config import get_core_logger logger = get_core_logger() -# JWT lifetime in seconds (Coinbase enforces max 2 minutes) -CDP_JWT_LIFETIME_SECONDS = 120 +CDP_API_HOST = "business.coinbase.com" +CDP_API_BASE_URL = f"https://{CDP_API_HOST}" -# Coinbase Business API base URL -CDP_API_BASE_URL = "https://api.coinbase.com" +# Sandbox adds /sandbox before the API path; production uses no prefix. +CDP_PATH_PREFIX = "/sandbox" if settings.CDP_SANDBOX else "" def _build_jwt(method: str, path: str) -> str: """ - Build a signed JWT for a CDP API request. + Build a signed JWT for a CDP API request using the CDP SDK. + + The path should already include the sandbox prefix when applicable. Args: method: HTTP method (GET, POST, etc.) - path: API path (e.g., /api/v1/payment-links) + path: Full API path (e.g., /sandbox/api/v1/payment-links or /api/v1/payment-links) Returns: Signed JWT string @@ -37,35 +38,20 @@ def _build_jwt(method: str, path: str) -> str: Raises: ValueError: If CDP API key credentials are not configured """ - if not settings.CDP_API_KEY_NAME or not settings.CDP_API_KEY_PRIVATE_KEY: + if not settings.CDP_API_KEY_ID or not settings.CDP_API_KEY_SECRET: raise ValueError( "CDP API credentials not configured. " - "Set CDP_API_KEY_NAME and CDP_API_KEY_PRIVATE_KEY environment variables." + "Set CDP_API_KEY_ID and CDP_API_KEY_SECRET environment variables." ) - now = int(time.time()) - uri = f"{method.upper()} api.coinbase.com{path}" - - payload = { - "sub": settings.CDP_API_KEY_NAME, - "iss": "cdp", - "nbf": now, - "exp": now + CDP_JWT_LIFETIME_SECONDS, - "uri": uri, - } - - headers = { - "alg": "ES256", - "typ": "JWT", - "kid": settings.CDP_API_KEY_NAME, - "nonce": secrets.token_hex(16), - } - - # CDP_API_KEY_PRIVATE_KEY is an EC PEM key. - # Environment variables may have literal \n — replace with real newlines. - private_key = settings.CDP_API_KEY_PRIVATE_KEY.replace("\\n", "\n") - - return jwt.encode(payload, private_key, algorithm="ES256", headers=headers) + return generate_jwt(JwtOptions( + api_key_id=settings.CDP_API_KEY_ID, + api_key_secret=settings.CDP_API_KEY_SECRET, + request_method=method.upper(), + request_host=CDP_API_HOST, + request_path=path, + expires_in=120, + )) def get_auth_headers(method: str, path: str) -> dict: @@ -74,7 +60,7 @@ def get_auth_headers(method: str, path: str) -> dict: Args: method: HTTP method - path: API path + path: Full API path (including sandbox prefix if applicable) Returns: Dict with Authorization header diff --git a/src/services/coinbase_payment_link_service.py b/src/services/coinbase_payment_link_service.py index 80517bb1..f47377dd 100644 --- a/src/services/coinbase_payment_link_service.py +++ b/src/services/coinbase_payment_link_service.py @@ -10,13 +10,13 @@ import httpx -from src.services.coinbase_auth import get_auth_headers, CDP_API_BASE_URL +from src.services.coinbase_auth import get_auth_headers, CDP_API_BASE_URL, CDP_PATH_PREFIX from src.core.logging_config import get_core_logger logger = get_core_logger() -# Payment Link API base path -PAYMENT_LINKS_PATH = "/api/v1/payment-links" +# Payment Link API base path (includes /sandbox prefix when CDP_SANDBOX=true) +PAYMENT_LINKS_PATH = f"{CDP_PATH_PREFIX}/api/v1/payment-links" # Default timeout for API calls (seconds) DEFAULT_TIMEOUT = 30.0 @@ -104,16 +104,16 @@ async def create_payment_link( async def list_payment_links( self, - limit: int = 25, - cursor: Optional[str] = None, + page_size: int = 20, + page_token: Optional[str] = None, status: Optional[str] = None, ) -> Dict[str, Any]: """ List payment links with optional filtering and pagination. Args: - limit: Max results per page (default 25) - cursor: Pagination cursor from a previous response + page_size: Max results per page (default 20, max 100) + page_token: Pagination token from a previous response's nextPageToken status: Filter by status (ACTIVE, COMPLETED, EXPIRED, DEACTIVATED) Returns: @@ -122,9 +122,9 @@ async def list_payment_links( path = PAYMENT_LINKS_PATH headers = get_auth_headers("GET", path) - params: Dict[str, Any] = {"limit": limit} - if cursor: - params["cursor"] = cursor + params: Dict[str, Any] = {"pageSize": page_size} + if page_token: + params["pageToken"] = page_token if status: params["status"] = status diff --git a/src/services/coinbase_webhook_service.py b/src/services/coinbase_webhook_service.py index 053c5424..07b3e08a 100644 --- a/src/services/coinbase_webhook_service.py +++ b/src/services/coinbase_webhook_service.py @@ -1,12 +1,12 @@ """ -Coinbase Business webhook service for processing Payment Link payment events. +Coinbase webhook service for processing payment events. -Handles Payment Link API events: -- payment_link.payment.success: Credits the user's account -- payment_link.payment.failed: Logged for monitoring -- payment_link.payment.expired: Logged for monitoring +Supports both: +- NEW: Payment Link API events (payment_link.payment.success/failed/expired) +- LEGACY: Commerce Charge API events (charge:confirmed, etc.) Docs: https://docs.cdp.coinbase.com/coinbase-business/payment-link-apis/webhooks +Migration: https://docs.cdp.coinbase.com/coinbase-business/payment-link-apis/migrate/overview """ from typing import Optional, Tuple, Dict, Any from decimal import Decimal @@ -24,7 +24,7 @@ class CoinbaseWebhookService: """ - Service for processing Coinbase Business Payment Link webhook events. + Service for processing Coinbase webhook events. All methods are idempotent - processing the same event multiple times will not result in duplicate credits or ledger entries. @@ -32,11 +32,18 @@ class CoinbaseWebhookService: SOURCE_NAME = "coinbase" - # ── Payment Link API Event Types ───────────────────────────────────── + # ── Payment Link API Event Types (New) ─────────────────────────────── EVENT_TYPE_PL_PAYMENT_SUCCESS = "payment_link.payment.success" EVENT_TYPE_PL_PAYMENT_FAILED = "payment_link.payment.failed" EVENT_TYPE_PL_PAYMENT_EXPIRED = "payment_link.payment.expired" + # ── Legacy Commerce Charge API Event Types (Deprecated) ────────────── + EVENT_TYPE_CHARGE_CONFIRMED = "charge:confirmed" + EVENT_TYPE_CHARGE_FAILED = "charge:failed" + EVENT_TYPE_CHARGE_DELAYED = "charge:delayed" + EVENT_TYPE_CHARGE_PENDING = "charge:pending" + EVENT_TYPE_CHARGE_RESOLVED = "charge:resolved" + # Currencies treated as USD-equivalent for crediting purposes # USDC is a stablecoin pegged 1:1 to USD USD_EQUIVALENT_CURRENCIES = {"USD", "USDC"} @@ -189,7 +196,7 @@ async def _create_purchase_entry( return entry # ===================================================================== - # Payment Link API Event Handlers + # Payment Link API Event Handlers (New) # ===================================================================== @staticmethod @@ -408,6 +415,133 @@ async def handle_payment_expired( return True, "Expired payment logged" + # ===================================================================== + # Legacy Commerce Charge API Handlers (Deprecated) + # ===================================================================== + + @staticmethod + def _validate_legacy_charge_amount( + pricing: Dict[str, Any], + log_context: Dict[str, Any], + ) -> Tuple[Optional[Decimal], Optional[str]]: + """ + Validate and extract USD amount from legacy charge pricing data. + + DEPRECATED: Will be removed after migration. + """ + local_pricing = pricing.get("local") or {} + currency = local_pricing.get("currency") + amount_str = local_pricing.get("amount") + + if not currency: + logger.error( + "Missing currency information in legacy Coinbase charge", + **log_context, + ) + return None, "Missing currency information" + + if currency != "USD": + logger.error( + f"Unsupported currency: {currency} (only USD accepted)", + currency=currency, + **log_context, + ) + return None, f"Unsupported currency: {currency}" + + if not amount_str: + return None, "Missing amount information" + + try: + amount_usd = Decimal(amount_str) + except Exception as e: + logger.error( + "Invalid amount format", + amount_str=amount_str, + error=str(e), + **log_context, + ) + return None, "Invalid amount format" + + if amount_usd <= 0: + logger.warning( + "Invalid payment amount (zero or negative)", + amount_usd=str(amount_usd), + **log_context, + ) + return None, "Invalid payment amount" + + return amount_usd, None + + async def handle_charge_confirmed( + self, + db: AsyncSession, + event_data: Dict[str, Any], + event_id: str, + event_type: str, + ) -> Tuple[bool, str]: + """ + Handle a legacy charge:confirmed event (payment received). + + DEPRECATED: Will be removed after migration to Payment Link API. + """ + charge_code = event_data.get("code") + charge_id = event_data.get("id") + transaction_id = charge_code + + log_context = { + "coinbase_charge_code": charge_code, + "coinbase_charge_id": charge_id, + } + + existing = await self._check_idempotency( + db, event_id, event_type, transaction_id, log_context + ) + if existing: + return True, "Already processed" + + metadata = event_data.get("metadata") or {} + user, error = await self._get_user_from_metadata( + db, metadata, log_context=log_context + ) + if error: + return False, error + + pricing = event_data.get("pricing") or {} + amount_usd, error = self._validate_legacy_charge_amount(pricing, log_context) + if error: + return False, error + + payment_metadata = { + "charge_id": charge_id, + "charge_code": charge_code, + "hosted_url": event_data.get("hosted_url"), + "created_at": event_data.get("created_at"), + "payments": event_data.get("payments", []), + "type": "charge", + } + + entry = await self._create_purchase_entry( + db=db, + user_id=user.id, + amount_usd=amount_usd, + event_id=event_id, + event_type=event_type, + transaction_id=transaction_id, + payment_metadata=payment_metadata, + description="Coinbase payment - Charge Confirmed (Legacy)", + ) + + logger.info( + "Processed legacy Coinbase charge:confirmed", + event_type=self.EVENT_TYPE_CHARGE_CONFIRMED, + user_id=user.id, + amount_usd=str(amount_usd), + ledger_entry_id=str(entry.id), + **log_context, + ) + + return True, "Credits added successfully" + # Singleton instance coinbase_webhook_service = CoinbaseWebhookService()