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?
- No Signature Verification - These endpoints don't have HMAC protection
- Internal Implementation - They're callback URLs embedded in invoices, not public API
- Security Through Obscurity - Reduces attack surface (though not primary security)
- 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:
- Extract
data.invoice.id(Paper.id invoice ID) - Find invoice in database by
paper_invoice_id - Check
invoice.status- only process if"paid" - Verify invoice not already processed (idempotency)
- Update invoice status to
PAIDin database - Route to appropriate handler based on
invoice_typeandmetadata - Return
200 OKto 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_idextracted 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:
Example:
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:
- ✅ Verify invoice status is
paid - ✅ Extract
subscription_idfrom invoice - ✅ Check
metadata.pending_upgradeexists - ✅ Create payment record with
PaymentType.SUBSCRIPTION - ✅ Call
subscription_crud.upgrade_subscription(): - Change
planto target plan - Keep
current_period_endunchanged - Update
last_payment_idandlast_payment_date - ✅ Clear
pending_upgradefrom subscription metadata - ✅ 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_upgradein 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:
- ✅ Verify invoice status is
paid - ✅ Extract
subscription_idfrom invoice - ✅ Check
metadata.pending_renewalexists - ✅ Create payment record with
PaymentType.SUBSCRIPTION - ✅ Call
subscription_crud.renew_subscription(): - Keep
planunchanged - Extend
current_period_endby billing cycle - Update
current_period_startto old end date - Set
next_billing_dateto new end date - ✅ Clear
pending_renewalfrom subscription metadata - ✅ Reactivate
PAST_DUEsubscriptions toACTIVE
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_renewalin 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:
- ✅ Verify invoice status is
paid - ✅ Extract
appointment_idfrom invoice - ✅ Check appointment
payment_status != PAID - ✅ Find pending payment record (or create new one)
- ✅ Update payment status to
COMPLETED - ✅ Update appointment:
- Set
payment_statustoPAID - Record
paid_amountandpayment_method - Set
paid_attimestamp - ✅ 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¶
- Running API Server
- ngrok Setup
Output:
- 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
- Open
paper_payment_urlin browser - Complete payment with test credentials
- 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:
- Check ngrok is running
Verify tunnel is active.
- Check ngrok web interface
Open: http://localhost:4040
Look for incoming POST requests to /webhooks/paper-invoice
-
Check Paper.id webhook logs
-
Log in to Paper.id dashboard
- Navigate to: Settings → Webhooks → Logs
-
Check for failed webhook deliveries
-
Check server logs
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:
- 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'])
- 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:
- Check webhook routing logs
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:
- Revert subscription to previous state manually
- Correct invoice metadata
- Resend webhook from Paper.id dashboard
Duplicate Processing¶
Symptoms: Subscription period extended twice, duplicate payments
Diagnostic Steps:
- Check webhook delivery logs
Paper.id dashboard → Webhooks → Logs
Look for multiple 200 OK responses for same invoice
- Check database for duplicate payments
payments = db.payments.find({
"invoice_id": ObjectId("507f1f77bcf86cd799439012")
})
print(f"Found {payments.count()} payments for this invoice")
- 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:
-
Revert duplicate changes manually:
-
Strengthen idempotency checks:
Invoice Not Found¶
Symptoms: Webhook returns "Invoice not found in our system"
Diagnostic Steps:
- Check invoice exists in database
- Check tenant ID matches
For tenant-specific webhook:
- Check invoice creation logs
Common Causes:
- ❌ Invoice creation failed during upgrade/renewal
- ❌ Wrong
paper_invoice_idin webhook payload - ❌ Database connection issue during creation
- ❌ Invoice belongs to different tenant
Fix:
- Recreate invoice manually if needed
- Verify Paper.id invoice ID matches
- Check database connectivity
Amount Mismatch¶
Symptoms: Webhook logs "Amount mismatch" error
Diagnostic Steps:
- Check invoice amount
invoice = db.invoices.find_one({"_id": ObjectId("...")})
print(f"Expected: {invoice['total_amount']}")
- 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:
- Verify proration calculation logic
- Ensure consistent currency usage
- 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:
- 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.
- Duplicate Processing
Alert if duplicate count > 10 in 1 hour.
- Webhook Response Time
Monitor response time < 5 seconds (Paper.id may timeout).
- 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 ✅¶
- Always return 200 OK
- Even if invoice not found
- Even if already processed
-
Prevents Paper.id from retrying
-
Log everything
-
Validate before processing
- Check invoice exists
- Verify tenant ownership
-
Confirm not already processed
-
Use database transactions
-
Set proper timeouts
DON'T ❌¶
- Don't trust webhook without validation
- Always check invoice in database
- Verify amounts match
-
Validate tenant ownership
-
Don't skip idempotency checks
- Always check if already processed
-
Use database status as source of truth
-
Don't perform long operations
- Keep webhook handler fast (< 5 seconds)
-
Queue background jobs if needed
-
Don't return 4xx/5xx errors
- Causes Paper.id to retry
-
Return 200 and log errors instead
-
Don't trust client-provided data
- Fetch invoice from database
- 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):
Response (Not Found):
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¶
- Two webhook strategies:
- Documented webhooks (with HMAC signature)
-
Internal webhooks (tenant validation instead)
-
Universal router pattern:
- Single endpoint handles all invoice types
-
Routes based on
invoice_typeandmetadata -
Security without signatures:
- Idempotency checks (CRITICAL)
- Tenant validation
- Amount verification
-
IP whitelisting
-
Always return 200 OK:
- Prevents Paper.id retries
-
Log errors for debugging
-
Test locally with ngrok:
- Monitor requests in ngrok UI
- Simulate webhooks manually
Next Steps¶
- Review Subscription Management for upgrade/renewal flows
- Test webhook routing with ngrok
- Set up production IP whitelisting
- Configure monitoring and alerts
- Review webhook logs regularly
Need Help?
- Check Integration Guide for Paper.id setup
- Review Troubleshooting section
- Test with Local Testing guide