Skip to content

Paper.id Webhook Integration

Complete guide to Paper.id payment webhooks, routing architecture, and event handling for subscription, appointment, and invoice payments.


Overview

The platform uses Paper.id as the payment gateway with webhook callbacks to handle real-time payment confirmations. This guide covers:

  • Webhook Architecture - Single endpoint with intelligent routing
  • Webhook Endpoints - Internal vs documented webhooks
  • Security Models - Signature verification vs tenant validation
  • Event Routing - Subscription upgrades, renewals, and appointments
  • Testing & Debugging - Local testing with ngrok
  • Production Setup - IP whitelisting and security

Key Concepts:

  • One Webhook URL Limitation - Paper.id only allows one webhook per account
  • Intelligent Routing - Single endpoint routes to multiple handlers
  • No Signature for Invoices - Invoice webhooks use tenant validation instead
  • Idempotency - Critical for preventing duplicate processing

Webhook Architecture

The Challenge

Paper.id has a fundamental limitation: you can only register one webhook URL per account.

With multiple payment types (subscriptions, appointments, withdrawals), we need different handlers but can only have one endpoint.

The Solution: Dual Webhook System

The platform implements two webhook strategies:

graph TB
    PaperID[Paper.id Payment Gateway]

    subgraph Documented Webhooks
        W1["/api/v1/webhooks/paper-id<br/>(Payment Requests)"]
        W2["/api/v1/webhooks/paper-id-invoice<br/>(B2B Invoices)"]
        W3["/api/v1/webhooks/paper-id-withdrawal<br/>(Payouts)"]
    end

    subgraph Internal Webhooks
        W4["/api/v1/webhooks/paper-invoice<br/>(Universal Router)"]
        W5["/api/v1/webhooks/paper-invoice/tenant/{id}<br/>(Tenant-Specific)"]
    end

    PaperID -->|HMAC Signature| W1
    PaperID -->|HMAC Signature| W2
    PaperID -->|HMAC Signature| W3
    PaperID -->|NO Signature| W4
    PaperID -->|NO Signature| W5

    W4 -->|Routes to| UpgradeHandler[Subscription Upgrade Handler]
    W4 -->|Routes to| RenewalHandler[Subscription Renewal Handler]
    W4 -->|Routes to| AppointmentHandler[Appointment Payment Handler]

    W5 -->|Routes to| StaffAppointment[Staff-Initiated Appointment]
    W5 -->|Routes to| CustomerAppointment[Customer-Initiated Appointment]

Webhook Types

Endpoint Visibility Signature Use Case
/webhooks/paper-id Visible in Swagger ✅ HMAC-SHA256 Payment request callbacks (wallet, subscriptions)
/webhooks/paper-id-invoice Visible in Swagger ✅ HMAC-SHA256 B2B invoice payments
/webhooks/paper-id-withdrawal Visible in Swagger ✅ HMAC-SHA256 Merchant payout confirmations
/webhooks/paper-invoice ❌ Hidden (include_in_schema=False) ❌ No signature Universal invoice router
/webhooks/paper-invoice/tenant/{id} ❌ Hidden (include_in_schema=False) ❌ No signature Tenant-specific appointments

Why Two Hidden Webhooks?

Security Design Decision

The /paper-invoice endpoints are intentionally excluded from Swagger/ReDoc documentation for security reasons:

Why Hidden?

  1. No Signature Verification - These endpoints don't have HMAC protection
  2. Internal Implementation - They're callback URLs embedded in invoices, not public API
  3. Security Through Obscurity - Reduces attack surface (though not primary security)
  4. Tenant-Specific URLs - Each tenant gets unique webhook URL with tenant ID

Alternative Security Measures:

  • Tenant Validation - Verify invoice belongs to correct tenant
  • Idempotency Checks - Prevent duplicate webhook processing
  • Amount Verification - Validate payment matches invoice
  • IP Whitelisting - (Production) Accept only from Paper.id IPs
  • HTTPS Transport - Encrypted communication

Why No Signature?

Paper.id's Sales Invoice API (used for subscription upgrades, renewals, and appointments) does not support webhook signatures. This is different from the Payment Request API which does support signatures.


Universal Invoice Router

Endpoint: /api/v1/webhooks/paper-invoice

This is the main routing hub for all invoice-based payments.

How It Works

graph TB
    Webhook[Paper.id Webhook Received]

    Webhook --> Parse[Parse Payload]
    Parse --> Extract[Extract invoice_id]
    Extract --> Lookup[Find Invoice in Database]

    Lookup --> Check{Check Invoice Type}

    Check -->|SUBSCRIPTION| SubCheck{Check Metadata}
    SubCheck -->|renewal: true| RenewalHandler[handle_invoice_payment_for_renewal]
    SubCheck -->|No renewal flag| UpgradeHandler[handle_invoice_payment_for_subscription]

    Check -->|APPOINTMENT| AppCheck{Check Metadata}
    AppCheck -->|customer_initiated: true| CustomerHandler[handle_customer_appointment_invoice]
    AppCheck -->|staff_initiated: true| StaffHandler[handle_invoice_payment_for_appointment]

    RenewalHandler --> Response[200 OK]
    UpgradeHandler --> Response
    CustomerHandler --> Response
    StaffHandler --> Response

Routing Logic

1. Subscription Invoices

if invoice.invoice_type == "SUBSCRIPTION":
    if invoice.metadata.get('renewal') == True:
        # RENEWAL: Extend period, same plan
        await handle_invoice_payment_for_renewal(invoice, payment_data, db)
    else:
        # UPGRADE: Change plan, same period
        await handle_invoice_payment_for_subscription(invoice, payment_data, db)

2. Appointment Invoices

if invoice.invoice_type == "APPOINTMENT":
    if invoice.metadata.get('customer_initiated') == True:
        # Customer booked online
        await handle_customer_appointment_invoice(invoice, payment_data, db)
    elif invoice.metadata.get('staff_initiated') == True:
        # Staff created payment link
        await handle_invoice_payment_for_appointment(invoice, payment_data, db)
    else:
        # Default to staff handler (backward compatibility)
        await handle_invoice_payment_for_appointment(invoice, payment_data, db)

Webhook Payload Example

From Paper.id:

{
  "message": "Invoice has been paid",
  "data": {
    "invoice": {
      "id": "4abe16db-4c4d-4fa3-a80c-b3aeeddab39f",
      "number": "INV-202510-68E427D7-00001",
      "partner_id": "d4a1a9b0-783b-428a-a029-ef869f6696fa",
      "status": "paid",
      "amount_due": 499000,
      "total_amount": 499000,
      "updated_at": "2025-10-07 11:04:57.331378778 +0700 WIB"
    }
  },
  "payment_info": {
    "method": "bank_transfer",
    "payment_id": "PAY_20251007_123456",
    "transaction_id": "TXN_789012"
  }
}

Processing Flow:

  1. Extract data.invoice.id (Paper.id invoice ID)
  2. Find invoice in database by paper_invoice_id
  3. Check invoice.status - only process if "paid"
  4. Verify invoice not already processed (idempotency)
  5. Update invoice status to PAID in database
  6. Route to appropriate handler based on invoice_type and metadata
  7. Return 200 OK to Paper.id

Tenant-Specific Webhook

Endpoint: /api/v1/webhooks/paper-invoice/tenant/{tenant_id}

This webhook is used for per-tenant appointment payments only.

Use Cases

1. Staff-Initiated Appointments

Staff creates appointment → Generates payment link → Customer pays → Webhook confirms

2. Customer-Initiated Appointments

Customer books online → Selects payment → Completes payment → Webhook confirms

Why Tenant-Specific?

Benefits:

  • Automatic Tenant Context - tenant_id extracted from URL path
  • Isolation Validation - Verify invoice belongs to tenant before processing
  • Multi-Tenant Support - Each tenant can have different Paper.id configs
  • Audit Trail - Clear tenant attribution in logs

Security:

# Step 1: Validate tenant exists
tenant = await tenant_crud.get(tenant_id)
if not tenant:
    raise HTTPException(status_code=404, detail="Tenant not found")

# Step 2: Check Paper.id is enabled
if not tenant.paper_id_config or not tenant.paper_id_config.enabled:
    raise HTTPException(status_code=400, detail="Paper.id not enabled")

# Step 3: Verify invoice belongs to this tenant
if str(invoice.tenant_id) != tenant_id:
    raise HTTPException(status_code=403, detail="Invoice does not belong to tenant")

# Step 4: Idempotency check
if invoice.status == InvoiceStatus.PAID:
    return {"status": "acknowledged", "message": "Already processed"}

Webhook URL Format

When creating an appointment invoice, the callback URL is:

https://api.myreserva.id/api/v1/webhooks/paper-invoice/tenant/{tenant_id}

Example:

https://api.myreserva.id/api/v1/webhooks/paper-invoice/tenant/507f1f77bcf86cd799439011

This URL is embedded in the Paper.id invoice, so Paper.id knows where to send the webhook.


Webhook Handlers

1. Subscription Upgrade Handler

Function: handle_invoice_payment_for_subscription()

Triggered When: - invoice_type == "SUBSCRIPTION" - NO renewal flag in metadata

What It Does:

graph LR
    A[Webhook Received] --> B[Validate Payment]
    B --> C[Get Subscription]
    C --> D[Check Pending Upgrade]
    D --> E[Create Payment Record]
    E --> F[Upgrade Subscription]
    F --> G[Clear Pending Metadata]
    G --> H[Send Confirmation]

Actions:

  1. ✅ Verify invoice status is paid
  2. ✅ Extract subscription_id from invoice
  3. ✅ Check metadata.pending_upgrade exists
  4. ✅ Create payment record with PaymentType.SUBSCRIPTION
  5. ✅ Call subscription_crud.upgrade_subscription():
  6. Change plan to target plan
  7. Keep current_period_end unchanged
  8. Update last_payment_id and last_payment_date
  9. ✅ Clear pending_upgrade from subscription metadata
  10. ✅ Return success with upgraded plan details

Response Example:

{
  "status": "success",
  "subscription_id": "507f1f77bcf86cd799439011",
  "upgraded_to": "PRO",
  "payment_id": "507f1f77bcf86cd799439020",
  "message": "Subscription upgraded to PRO plan"
}

Key Business Rules:

  • ❌ Won't process if no pending_upgrade in metadata
  • ❌ Won't process if invoice already marked as paid
  • ✅ Validates invoice belongs to correct subscription
  • ✅ Prorated amount already calculated during upgrade initiation

2. Subscription Renewal Handler

Function: handle_invoice_payment_for_renewal()

Triggered When: - invoice_type == "SUBSCRIPTION" - renewal: true in metadata

What It Does:

graph LR
    A[Webhook Received] --> B[Validate Payment]
    B --> C[Get Subscription]
    C --> D[Check Pending Renewal]
    D --> E[Create Payment Record]
    E --> F[Extend Subscription Period]
    F --> G[Clear Pending Metadata]
    G --> H[Send Confirmation]

Actions:

  1. ✅ Verify invoice status is paid
  2. ✅ Extract subscription_id from invoice
  3. ✅ Check metadata.pending_renewal exists
  4. ✅ Create payment record with PaymentType.SUBSCRIPTION
  5. ✅ Call subscription_crud.renew_subscription():
  6. Keep plan unchanged
  7. Extend current_period_end by billing cycle
  8. Update current_period_start to old end date
  9. Set next_billing_date to new end date
  10. ✅ Clear pending_renewal from subscription metadata
  11. ✅ Reactivate PAST_DUE subscriptions to ACTIVE

Response Example:

{
  "status": "success",
  "subscription_id": "507f1f77bcf86cd799439011",
  "renewed_until": "2025-03-15",
  "payment_id": "507f1f77bcf86cd799439021",
  "message": "Subscription renewed until 2025-03-15"
}

Key Business Rules:

  • ✅ Charges full period amount (no proration)
  • ✅ Can renew even if subscription hasn't expired (early renewal)
  • ❌ Won't process if no pending_renewal in metadata
  • ✅ Extends from current_period_end, not from payment date

3. Staff-Initiated Appointment Handler

Function: handle_invoice_payment_for_appointment()

Triggered When: - invoice_type == "APPOINTMENT" - staff_initiated: true in metadata (or no flag for backward compatibility)

What It Does:

graph LR
    A[Webhook Received] --> B[Validate Payment]
    B --> C[Get Appointment]
    C --> D[Check Not Already Paid]
    D --> E[Update/Create Payment]
    E --> F[Update Appointment Status]
    F --> G[Send Confirmation]

Actions:

  1. ✅ Verify invoice status is paid
  2. ✅ Extract appointment_id from invoice
  3. ✅ Check appointment payment_status != PAID
  4. ✅ Find pending payment record (or create new one)
  5. ✅ Update payment status to COMPLETED
  6. ✅ Update appointment:
  7. Set payment_status to PAID
  8. Record paid_amount and payment_method
  9. Set paid_at timestamp
  10. ✅ Send confirmation to customer and staff

Response Example:

{
  "status": "success",
  "appointment_id": "507f1f77bcf86cd799439030",
  "payment_id": "507f1f77bcf86cd799439031",
  "amount": 150000,
  "message": "Appointment payment confirmed"
}

Key Business Rules:

  • ✅ Idempotent - safe to call multiple times
  • ✅ Creates payment record if none exists (race condition handling)
  • ❌ Won't process if appointment already marked as paid
  • ✅ Validates appointment belongs to correct tenant

4. Customer-Initiated Appointment Handler

Function: handle_customer_appointment_invoice()

Triggered When: - invoice_type == "APPOINTMENT" - customer_initiated: true in metadata

What It Does:

Same as staff-initiated handler, plus:

Auto-confirms appointment - Sets status to CONFIRMED (not just payment status)

Key Difference:

Aspect Staff-Initiated Customer-Initiated
Payment Status Set to PAID Set to PAID
Appointment Status No change Auto-confirm to CONFIRMED
Use Case Staff generates link after booking Customer books and pays immediately

Response Example:

{
  "status": "success",
  "appointment_id": "507f1f77bcf86cd799439030",
  "payment_id": "507f1f77bcf86cd799439031",
  "amount": 150000,
  "message": "Customer appointment payment confirmed and appointment auto-confirmed"
}

Webhook Security

Challenge: No Signature Verification

Paper.id Sales Invoice API does not provide HMAC-SHA256 signatures like the Payment Request API does.

Why?

  • Different API endpoints (Sales Invoice vs Payment Request)
  • Sales Invoice API is simpler, designed for direct invoicing
  • Webhook signature is optional feature

Security Measures (Without Signatures)

1. Idempotency Checks (CRITICAL)

Problem: Paper.id may send duplicate webhooks (network retries, timeouts)

Solution:

# Check if invoice already processed
if invoice.status == InvoiceStatus.PAID:
    logger.info(f"Invoice {invoice.id} already processed")
    return {
        "status": "acknowledged",
        "message": "Invoice already processed"
    }

Why Critical: Without signatures, idempotency is the ONLY way to prevent: - Duplicate subscription renewals (double-charging period) - Multiple appointment confirmations - Duplicate payment records

2. Tenant Validation

Verify invoice belongs to tenant:

if str(invoice.tenant_id) != tenant_id:
    logger.error(f"Invoice {invoice.id} does not belong to tenant {tenant_id}")
    raise HTTPException(status_code=403, detail="Access denied")

3. Amount Verification

Validate payment amount matches invoice:

if payment_data['amount'] != invoice.total_amount:
    logger.error(f"Amount mismatch: expected {invoice.total_amount}, got {payment_data['amount']}")
    raise HTTPException(status_code=400, detail="Amount mismatch")

4. IP Whitelisting (Production)

Nginx/Firewall Configuration:

location /api/v1/webhooks/paper-invoice {
    # Only allow Paper.id IPs
    allow 103.xx.xx.xx;  # Paper.id IP range 1
    allow 202.xx.xx.xx;  # Paper.id IP range 2
    deny all;

    proxy_pass http://backend;
}

Request from Paper.id support for IP whitelist.

5. HTTPS Transport

Always use HTTPS for webhook URLs:

✅ https://api.myreserva.id/api/v1/webhooks/paper-invoice
❌ http://api.myreserva.id/api/v1/webhooks/paper-invoice

6. Database Validation

Verify invoice exists before processing:

invoice = await invoice_crud.get_by_paper_invoice_id(paper_invoice_id)
if not invoice:
    logger.warning(f"Invoice {paper_invoice_id} not found")
    return {
        "status": "acknowledged",
        "message": "Invoice not found in our system"
    }

Why Return 200? Always return 200 OK to prevent Paper.id retries, even if invoice not found (may be from different system).


Testing Webhooks Locally

Prerequisites

  1. Running API Server
uvicorn app.main:app --reload --port 8000
  1. ngrok Setup
ngrok http 8000

Output:

Forwarding: https://abc123.ngrok.io -> http://localhost:8000

  1. ngrok Web Interface

Open: http://localhost:4040

Monitor incoming webhook requests in real-time.


Testing Subscription Renewal

Scenario: Renew PRO Monthly subscription

Step 1: Create Renewal Invoice

curl -X POST http://localhost:8000/api/v1/subscriptions/renew \
  -H "Authorization: Bearer YOUR_JWT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "subscription_id": "507f1f77bcf86cd799439011"
  }'

Response:

{
  "status": "payment_pending",
  "invoice": {
    "id": "507f1f77bcf86cd799439012",
    "paper_invoice_id": "PI-20250115-ABC123",
    "paper_payment_url": "https://payper.id/short456",
    "amount": "499900"
  },
  "renewal_details": {
    "next_period_start": "2025-02-15",
    "next_period_end": "2025-03-15"
  }
}

Step 2: Simulate Payment

Option A: Use Paper.id Test Environment

  1. Open paper_payment_url in browser
  2. Complete payment with test credentials
  3. Paper.id sends webhook to your ngrok URL

Option B: Manual Webhook Simulation

curl -X POST https://abc123.ngrok.io/api/v1/webhooks/paper-invoice \
  -H "Content-Type: application/json" \
  -d '{
    "message": "Invoice has been paid",
    "data": {
      "invoice": {
        "id": "PI-20250115-ABC123",
        "status": "paid",
        "amount_due": 499900,
        "total_amount": 499900
      }
    },
    "payment_info": {
      "method": "bank_transfer",
      "payment_id": "PAY_TEST_123"
    }
  }'

Step 3: Verify Webhook Processing

Check Server Logs:

INFO:     Processing Paper.id invoice webhook for invoice_id: PI-20250115-ABC123, status: paid
INFO:     Processing subscription RENEWAL for invoice 507f1f77bcf86cd799439012
INFO:     Successfully renewed subscription 507f1f77bcf86cd799439011 until 2025-03-15

Verify Routing:

Look for: "Processing subscription RENEWAL" (not UPGRADE)

Step 4: Verify Subscription Extended

curl -X GET http://localhost:8000/api/v1/subscriptions/current \
  -H "Authorization: Bearer YOUR_JWT_TOKEN"

Expected:

{
  "subscription_id": "507f1f77bcf86cd799439011",
  "plan_type": "PRO",
  "current_period_start": "2025-02-15",
  "current_period_end": "2025-03-15",  // Extended by 30 days
  "last_payment_date": "2025-01-15T10:30:00Z"
}

Testing Subscription Upgrade

Scenario: Upgrade from FREE to PRO mid-cycle

Step 1: Create Upgrade Invoice

curl -X POST http://localhost:8000/api/v1/subscriptions/upgrade \
  -H "Authorization: Bearer YOUR_JWT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "target_plan": "pro",
    "billing_period": "monthly",
    "prorate_charges": true
  }'

Response:

{
  "status": "payment_pending",
  "upgrade_details": {
    "from_plan": "free",
    "to_plan": "pro",
    "prorated_amount": "249950",
    "days_remaining": 15
  },
  "invoice": {
    "id": "507f1f77bcf86cd799439013",
    "paper_invoice_id": "PI-20250115-DEF456",
    "paper_payment_url": "https://payper.id/short789",
    "amount": "249950"
  }
}

Step 2: Simulate Webhook

curl -X POST https://abc123.ngrok.io/api/v1/webhooks/paper-invoice \
  -H "Content-Type: application/json" \
  -d '{
    "message": "Invoice has been paid",
    "data": {
      "invoice": {
        "id": "PI-20250115-DEF456",
        "status": "paid",
        "amount_due": 249950,
        "total_amount": 249950
      }
    },
    "payment_info": {
      "method": "bank_transfer",
      "payment_id": "PAY_TEST_456"
    }
  }'

Step 3: Verify Webhook Routing

Check Logs:

INFO:     Processing Paper.id invoice webhook for invoice_id: PI-20250115-DEF456, status: paid
INFO:     Processing subscription UPGRADE for invoice 507f1f77bcf86cd799439013
INFO:     Successfully upgraded subscription 507f1f77bcf86cd799439011 from FREE to PRO

Verify Routing:

Look for: "Processing subscription UPGRADE" (NO renewal flag)

Step 4: Verify Plan Changed

curl -X GET http://localhost:8000/api/v1/subscriptions/current \
  -H "Authorization: Bearer YOUR_JWT_TOKEN"

Expected:

{
  "subscription_id": "507f1f77bcf86cd799439011",
  "plan_type": "PRO",  // Changed from FREE
  "current_period_end": "2025-02-15",  // UNCHANGED
  "last_payment_amount": 249950
}

Testing Tenant Appointment Webhook

Scenario: Customer pays for appointment via tenant-specific webhook

Step 1: Create Appointment Invoice

curl -X POST http://localhost:8000/api/v1/customer/payments/create-appointment-invoice \
  -H "Authorization: Bearer CUSTOMER_JWT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "appointment_id": "507f1f77bcf86cd799439030"
  }'

Response:

{
  "status": "payment_pending",
  "invoice": {
    "id": "507f1f77bcf86cd799439031",
    "paper_invoice_id": "PI-20250115-GHI789",
    "paper_payment_url": "https://payper.id/short999",
    "amount": "150000"
  },
  "appointment": {
    "id": "507f1f77bcf86cd799439030",
    "service_name": "Haircut & Styling",
    "scheduled_at": "2025-01-20T14:00:00Z"
  }
}

Step 2: Simulate Tenant Webhook

Note: Use tenant-specific endpoint

curl -X POST https://abc123.ngrok.io/api/v1/webhooks/paper-invoice/tenant/507f1f77bcf86cd799439010 \
  -H "Content-Type: application/json" \
  -d '{
    "message": "Invoice has been paid",
    "data": {
      "invoice": {
        "id": "PI-20250115-GHI789",
        "status": "paid",
        "amount_due": 150000,
        "total_amount": 150000
      }
    },
    "payment_info": {
      "method": "bank_transfer",
      "payment_id": "PAY_TEST_789"
    }
  }'

Step 3: Verify Processing

Check Logs:

INFO:     Received Paper.id tenant webhook: tenant_id=507f1f77bcf86cd799439010, invoice_id=PI-20250115-GHI789
INFO:     Routing to CUSTOMER handler for tenant invoice 507f1f77bcf86cd799439031
INFO:     Successfully processed customer appointment payment for appointment 507f1f77bcf86cd799439030

Step 4: Verify Appointment Paid

curl -X GET http://localhost:8000/api/v1/customer/appointments/507f1f77bcf86cd799439030 \
  -H "Authorization: Bearer CUSTOMER_JWT_TOKEN"

Expected:

{
  "id": "507f1f77bcf86cd799439030",
  "status": "CONFIRMED",  // Auto-confirmed on payment
  "payment_status": "PAID",
  "paid_amount": 150000,
  "paid_at": "2025-01-15T11:30:00Z"
}

Troubleshooting

Webhook Not Received

Symptoms: Payment completed but subscription/appointment not updated

Diagnostic Steps:

  1. Check ngrok is running
curl http://localhost:4040/api/tunnels

Verify tunnel is active.

  1. Check ngrok web interface

Open: http://localhost:4040

Look for incoming POST requests to /webhooks/paper-invoice

  1. Check Paper.id webhook logs

  2. Log in to Paper.id dashboard

  3. Navigate to: Settings → Webhooks → Logs
  4. Check for failed webhook deliveries

  5. Check server logs

tail -f logs/app.log | grep "webhook"

Common Causes:

  • ❌ ngrok tunnel expired (free tier has 2-hour limit)
  • ❌ Firewall blocking incoming webhooks
  • ❌ Wrong webhook URL configured in Paper.id
  • ❌ Server crashed/restarted

Fix:

  • Restart ngrok and update Paper.id webhook URL
  • Or manually trigger webhook from Paper.id dashboard (Resend Webhook)

Wrong Handler Invoked

Symptoms: Renewal processed as upgrade (or vice versa)

Diagnostic Steps:

  1. Check invoice metadata in database
from pymongo import MongoClient
client = MongoClient("mongodb://localhost:27017")
db = client["circe_db"]

invoice = db.invoices.find_one({"paper_invoice_id": "PI-..."})
print(invoice['metadata'])
  1. Verify renewal flag

Renewal invoice should have:

{
  "metadata": {
    "renewal": true,
    "subscription_id": "...",
    "next_period_start": "2025-02-15",
    "next_period_end": "2025-03-15"
  }
}

Upgrade invoice should NOT have renewal flag:

{
  "metadata": {
    "subscription_id": "...",
    "previous_plan": "FREE",
    "new_plan": "PRO"
  }
}

  1. Check webhook routing logs
grep "Processing subscription" logs/app.log

Should see: "Processing subscription RENEWAL" or "Processing subscription UPGRADE"

Common Causes:

  • ❌ Invoice metadata incorrectly set during invoice creation
  • ❌ Webhook routing logic bug
  • ❌ Database inconsistency

Fix:

  1. Revert subscription to previous state manually
  2. Correct invoice metadata
  3. Resend webhook from Paper.id dashboard

Duplicate Processing

Symptoms: Subscription period extended twice, duplicate payments

Diagnostic Steps:

  1. Check webhook delivery logs

Paper.id dashboard → Webhooks → Logs

Look for multiple 200 OK responses for same invoice

  1. Check database for duplicate payments
payments = db.payments.find({
    "invoice_id": ObjectId("507f1f77bcf86cd799439012")
})
print(f"Found {payments.count()} payments for this invoice")
  1. Check idempotency logic

Review webhook handler code for idempotency checks

Common Causes:

  • ❌ Idempotency check not working
  • ❌ Database transaction race condition
  • ❌ Paper.id sent multiple webhooks (network retry)

Fix:

  1. Revert duplicate changes manually:

    # Revert subscription period
    db.subscriptions.update_one(
        {"_id": ObjectId("...")},
        {"$set": {"current_period_end": original_end_date}}
    )
    
    # Delete duplicate payment
    db.payments.delete_one({"_id": ObjectId("duplicate_payment_id")})
    

  2. Strengthen idempotency checks:

    # Add database-level unique constraint
    db.invoices.create_index(
        [("paper_invoice_id", 1)],
        unique=True
    )
    


Invoice Not Found

Symptoms: Webhook returns "Invoice not found in our system"

Diagnostic Steps:

  1. Check invoice exists in database
invoice = db.invoices.find_one({
    "paper_invoice_id": "PI-20250115-ABC123"
})
print(invoice)
  1. Check tenant ID matches

For tenant-specific webhook:

print(f"Invoice tenant: {invoice['tenant_id']}")
print(f"Webhook tenant: {tenant_id}")

  1. Check invoice creation logs
grep "Created invoice" logs/app.log | grep "PI-20250115-ABC123"

Common Causes:

  • ❌ Invoice creation failed during upgrade/renewal
  • ❌ Wrong paper_invoice_id in webhook payload
  • ❌ Database connection issue during creation
  • ❌ Invoice belongs to different tenant

Fix:

  1. Recreate invoice manually if needed
  2. Verify Paper.id invoice ID matches
  3. Check database connectivity

Amount Mismatch

Symptoms: Webhook logs "Amount mismatch" error

Diagnostic Steps:

  1. Check invoice amount
invoice = db.invoices.find_one({"_id": ObjectId("...")})
print(f"Expected: {invoice['total_amount']}")
  1. Check webhook payload amount

ngrok web interface → Inspect webhook request body

Common Causes:

  • ❌ Proration calculation error during upgrade
  • ❌ Currency mismatch (IDR vs USD)
  • ❌ Decimal vs integer amount issue

Fix:

  1. Verify proration calculation logic
  2. Ensure consistent currency usage
  3. Update invoice amount if needed

Production Deployment

1. Register Webhook URL with Paper.id

Contact Paper.id Support:

Email: support@paper.id

Request:

Subject: Webhook URL Registration for Account [YOUR_ACCOUNT_ID]

Hello Paper.id Team,

Please register the following webhook URL for our account:

Webhook URL: https://api.myreserva.id/api/v1/webhooks/paper-invoice
HTTP Method: POST
Content-Type: application/json

Please also provide:
1. Paper.id server IP addresses for whitelisting
2. Expected webhook retry behavior
3. Webhook timeout settings

Thank you!

2. IP Whitelisting (CRITICAL)

Nginx Configuration:

# /etc/nginx/sites-available/api.myreserva.id

# Webhook endpoint with IP whitelist
location /api/v1/webhooks/paper-invoice {
    # Only allow Paper.id IPs (get from Paper.id support)
    allow 103.xx.xx.xx/24;  # Paper.id IP range 1
    allow 202.xx.xx.xx/24;  # Paper.id IP range 2
    deny all;

    # Forward to backend
    proxy_pass http://127.0.0.1:8000;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

# Other API endpoints (no IP restriction)
location /api/v1/ {
    proxy_pass http://127.0.0.1:8000;
    # ... same proxy settings
}

Test IP Whitelist:

# Should succeed from Paper.id IP
curl -X POST https://api.myreserva.id/api/v1/webhooks/paper-invoice \
  -H "Content-Type: application/json" \
  -d '{"test": "data"}'

# Should fail from other IPs
# Response: 403 Forbidden

3. Monitoring & Alerting

Set up alerts for:

  1. Webhook Processing Failures
# Log webhook errors
logger.error(f"Webhook processing failed: {str(e)}", extra={
    "invoice_id": invoice_id,
    "tenant_id": tenant_id,
    "error": str(e)
})

Alert if error count > 5 in 10 minutes.

  1. Duplicate Processing
# Log duplicate attempts
logger.warning(f"Duplicate webhook for invoice {invoice_id}")

Alert if duplicate count > 10 in 1 hour.

  1. Webhook Response Time

Monitor response time < 5 seconds (Paper.id may timeout).

  1. Idempotency Check Failures

Alert if same invoice processed multiple times.


4. Database Indexes

Critical indexes for webhook performance:

# Invoice lookup by Paper.id invoice ID
db.invoices.create_index([("paper_invoice_id", 1)], unique=True)

# Payment lookup by invoice
db.payments.create_index([("invoice_id", 1)])

# Subscription lookup
db.subscriptions.create_index([("_id", 1), ("tenant_id", 1)])

# Appointment lookup
db.appointments.create_index([("_id", 1), ("tenant_id", 1)])

5. Load Testing

Simulate concurrent webhooks:

# 100 concurrent webhook requests
ab -n 100 -c 10 -T application/json -p webhook_payload.json \
  https://api.myreserva.id/api/v1/webhooks/paper-invoice

Target: All requests complete in < 5 seconds


Best Practices

DO ✅

  1. Always return 200 OK
  2. Even if invoice not found
  3. Even if already processed
  4. Prevents Paper.id from retrying

  5. Log everything

    logger.info(f"Webhook received: {paper_invoice_id}")
    logger.info(f"Routing to {handler_name}")
    logger.info(f"Processing result: {result}")
    

  6. Validate before processing

  7. Check invoice exists
  8. Verify tenant ownership
  9. Confirm not already processed

  10. Use database transactions

    async with db.start_session() as session:
        async with session.start_transaction():
            # Update invoice
            # Update subscription
            # Create payment
    

  11. Set proper timeouts

    # Webhook handler should complete in < 5 seconds
    asyncio.wait_for(process_webhook(), timeout=5.0)
    

DON'T ❌

  1. Don't trust webhook without validation
  2. Always check invoice in database
  3. Verify amounts match
  4. Validate tenant ownership

  5. Don't skip idempotency checks

  6. Always check if already processed
  7. Use database status as source of truth

  8. Don't perform long operations

  9. Keep webhook handler fast (< 5 seconds)
  10. Queue background jobs if needed

  11. Don't return 4xx/5xx errors

  12. Causes Paper.id to retry
  13. Return 200 and log errors instead

  14. Don't trust client-provided data

  15. Fetch invoice from database
  16. Don't accept amounts from webhook

Webhook Payload Reference

Universal Invoice Webhook

URL: POST /api/v1/webhooks/paper-invoice

Headers: - Content-Type: application/json - NO X-Paper-Signature header

Body:

{
  "message": "Invoice has been paid",
  "data": {
    "invoice": {
      "id": "4abe16db-4c4d-4fa3-a80c-b3aeeddab39f",
      "number": "INV-202510-68E427D7-00001",
      "partner_id": "d4a1a9b0-783b-428a-a029-ef869f6696fa",
      "status": "paid",
      "amount_due": 499000,
      "total_amount": 499000,
      "currency": "IDR",
      "due_date": "2025-10-14",
      "created_at": "2025-10-07 10:30:00",
      "updated_at": "2025-10-07 11:04:57"
    }
  },
  "payment_info": {
    "method": "bank_transfer",
    "payment_id": "PAY_20251007_123456",
    "transaction_id": "TXN_789012",
    "paid_at": "2025-10-07 11:04:57"
  }
}

Response (Success):

{
  "status": "success",
  "message": "Invoice webhook processed successfully",
  "invoice_id": "4abe16db-4c4d-4fa3-a80c-b3aeeddab39f",
  "invoice_status": "paid",
  "renewal_result": {
    "status": "success",
    "subscription_id": "507f1f77bcf86cd799439011",
    "renewed_until": "2025-11-07"
  }
}

Response (Already Processed):

{
  "status": "acknowledged",
  "message": "Invoice already processed"
}

Response (Not Found):

{
  "status": "acknowledged",
  "message": "Invoice not found in our system"
}

Tenant-Specific Webhook

URL: POST /api/v1/webhooks/paper-invoice/tenant/{tenant_id}

Path Parameters: - tenant_id (required) - Tenant ID (MongoDB ObjectId)

Headers: - Content-Type: application/json

Body: Same as universal webhook

Response (Success):

{
  "status": "success",
  "message": "Tenant webhook processed successfully",
  "tenant_id": "507f1f77bcf86cd799439010",
  "invoice_id": "PI-20250107-ABC123",
  "invoice_status": "paid",
  "appointment_result": {
    "status": "success",
    "appointment_id": "507f1f77bcf86cd799439030",
    "payment_id": "507f1f77bcf86cd799439031",
    "amount": 150000
  }
}

Summary

Key Takeaways

  1. Two webhook strategies:
  2. Documented webhooks (with HMAC signature)
  3. Internal webhooks (tenant validation instead)

  4. Universal router pattern:

  5. Single endpoint handles all invoice types
  6. Routes based on invoice_type and metadata

  7. Security without signatures:

  8. Idempotency checks (CRITICAL)
  9. Tenant validation
  10. Amount verification
  11. IP whitelisting

  12. Always return 200 OK:

  13. Prevents Paper.id retries
  14. Log errors for debugging

  15. Test locally with ngrok:

  16. Monitor requests in ngrok UI
  17. Simulate webhooks manually

Next Steps

  1. Review Subscription Management for upgrade/renewal flows
  2. Test webhook routing with ngrok
  3. Set up production IP whitelisting
  4. Configure monitoring and alerts
  5. Review webhook logs regularly

Need Help?