Webhooks and Notifications

Webhooks and Notifications

What are Webhooks?

Webhooks are HTTP POST notifications sent to your server in real-time when subscription or payment events occur. They allow you to react automatically to changes in subscription status, payment processing, and other important events without polling the API.

Configuration

Webhook URL

You must provide a notificationUrl when creating a subscription. This URL will receive all webhook notifications for that subscription and its payments.

Requirements:

  • Must be publicly accessible (HTTPS)
  • Must accept POST requests
  • Must return 200 OK responses
  • Should respond quickly (process asynchronously if needed)

Example:

{
  "notificationUrl": "https://your-domain.com/webhooks/subscriptions"
}

Authentication

The WebhookSender service handles webhook delivery, including:

  • Retry logic for failed deliveries
  • Rate limiting
  • Webhook signature generation
  • Delivery confirmation

Your webhook endpoint should validate the webhook signature if provided by the WebhookSender service.

Event Types

Subscription Events

subscription_activation

Triggered when a subscription is activated (user authorizes the subscription).

When it occurs:

  • User authorizes subscription via Journey 1 (push notification)
  • User authorizes subscription via Journey 2 (QR code scan)

Payload:

{
  "eventType": "subscription_activation",
  "eventId": "event-guid-123",
  "eventDate": "2025-01-15T10:30:00Z",
  "entityId": "subscription-guid",
  "status": "ACTIVE",
  "statusDate": "2025-01-15T10:30:00Z",
  "message": null,
  "errorCode": null,
  "errorMessage": null,
  "pspReference": null,
  "end2EndId": null,
  "additionalData": []
}

subscription_cancellation

Triggered when a subscription is cancelled.

When it occurs:

  • Merchant cancels subscription via API
  • User cancels subscription via their bank/app
  • Subscription expires automatically

Payload:

{
  "eventType": "subscription_cancellation",
  "eventId": "event-guid-124",
  "eventDate": "2025-01-20T14:00:00Z",
  "entityId": "subscription-guid",
  "status": "CANCELLED",
  "statusDate": "2025-01-20T14:00:00Z",
  "message": "Cancelled by user",
  "errorCode": null,
  "errorMessage": null,
  "pspReference": null,
  "end2EndId": null,
  "additionalData": []
}

Payment Events

subscription.payment (PAID)

Triggered when a payment is successfully processed.

When it occurs:

  • Payment is successfully processed by the payment provider
  • Retry attempt succeeds

Payload:

{
  "eventType": "subscription.payment",
  "eventId": "event-guid-125",
  "eventDate": "2025-01-15T10:35:00Z",
  "entityId": "payment-guid",
  "status": "PAID",
  "statusDate": "2025-01-15T10:35:00Z",
  "message": null,
  "errorCode": null,
  "errorMessage": null,
  "pspReference": "123456-7890-acasfsa-23424",
  "end2EndId": "E-1323434-acac-3214324-adasd",
  "additionalData": []
}

subscription.payment (FAILED)

Triggered when a payment fails.

When it occurs:

  • Payment fails due to insufficient funds
  • Payment fails due to account closure
  • Payment fails due to authorization revocation
  • Payment fails for any other reason

Payload:

{
  "eventType": "subscription.payment",
  "eventId": "event-guid-126",
  "eventDate": "2025-01-15T10:40:00Z",
  "entityId": "payment-guid",
  "status": "FAILED",
  "statusDate": "2025-01-15T10:40:00Z",
  "message": null,
  "errorCode": "INSUFFICIENT_FUNDS",
  "errorMessage": "Insufficient funds in account",
  "pspReference": "123456-7890-acasfsa-23425",
  "end2EndId": null,
  "additionalData": [
    {
      "key": "retryCount",
      "value": "1"
    }
  ]
}

subscription.payment (CANCELLED)

Triggered when a payment is cancelled.

When it occurs:

  • Merchant cancels payment via API
  • Payment is cancelled due to timing restrictions (e.g., PIX cancellation deadline)
  • Subscription is cancelled before payment is processed

Payload:

{
  "eventType": "subscription.payment",
  "eventId": "event-guid-127",
  "eventDate": "2025-01-15T11:00:00Z",
  "entityId": "payment-guid",
  "status": "CANCELLED",
  "statusDate": "2025-01-15T11:00:00Z",
  "message": "Cancelled by merchant",
  "errorCode": null,
  "errorMessage": null,
  "pspReference": null,
  "end2EndId": null,
  "additionalData": []
}

subscription.payment_schedule

Triggered when the next payment is automatically scheduled.

When it occurs:

  • System automatically schedules the next payment (48 hours before due date)
  • Only sent if automatic scheduling is enabled

Payload:

{
  "eventType": "subscription.payment_schedule",
  "eventId": "event-guid-128",
  "eventDate": "2025-01-13T10:00:00Z",
  "entityId": "payment-guid",
  "status": "PENDING",
  "statusDate": "2025-01-13T10:00:00Z",
  "message": null,
  "errorCode": null,
  "errorMessage": null,
  "pspReference": null,
  "end2EndId": null,
  "additionalData": [
    {
      "key": "scheduledDate",
      "value": "2025-01-15T10:00:00Z"
    }
  ]
}

subscription.payment_cancellation

Triggered when a payment cancellation is requested.

When it occurs:

  • Merchant requests payment cancellation
  • System processes payment cancellation

Payload:

{
  "eventType": "subscription.payment_cancellation",
  "eventId": "event-guid-129",
  "eventDate": "2025-01-15T12:00:00Z",
  "entityId": "payment-guid",
  "status": "CANCELLED",
  "statusDate": "2025-01-15T12:00:00Z",
  "message": "Payment cancellation requested",
  "errorCode": null,
  "errorMessage": null,
  "pspReference": null,
  "end2EndId": null,
  "additionalData": []
}

Payload Structure

All webhook payloads follow this structure:

{
  "eventType": "string",
  "eventId": "string",
  "eventDate": "ISO8601 datetime",
  "entityId": "string",
  "status": "string",
  "statusDate": "ISO8601 datetime",
  "message": "string | null",
  "errorCode": "string | null",
  "errorMessage": "string | null",
  "pspReference": "string | null",
  "end2EndId": "string | null",
  "additionalData": [
    {
      "key": "string",
      "value": "string"
    }
  ]
}

Field Descriptions

  • eventType: Type of event (e.g., subscription_activation, subscription.payment)
  • eventId: Unique identifier for this event (use for idempotency)
  • eventDate: UTC timestamp when the event occurred
  • entityId: ID of the entity that triggered the event (SubscriptionPayment.Id or Subscription.Id)
  • status: Current status of the entity (e.g., ACTIVE, PAID, FAILED)
  • statusDate: UTC timestamp when the status changed
  • message: Optional message (e.g., cancellation reason)
  • errorCode: Error code if applicable (e.g., INSUFFICIENT_FUNDS)
  • errorMessage: Human-readable error message
  • pspReference: Payment Service Provider reference (for payments)
  • end2EndId: End-to-End ID (PIX-specific identifier)
  • additionalData: Additional context data (key-value pairs)

Common Error Codes

Payment Errors

  • INSUFFICIENT_FUNDS: Account has insufficient funds
  • REJECTED_BY_BANK: Payment rejected by the bank
  • CANCELLED_BY_USER: User cancelled the payment
  • ACCOUNT_CLOSED: User's account is closed
  • AUTHORIZATION_REVOKED: User revoked subscription authorization
  • PAYMENT_EXPIRED: Payment expired before processing
  • INVALID_ACCOUNT: Invalid account information
  • LIMIT_EXCEEDED: Transaction limit exceeded

Subscription Errors

  • AUTHORIZATION_REJECTED: User rejected subscription authorization
  • INVALID_CUSTOMER: Customer information is invalid
  • CONFIGURATION_ERROR: Subscription configuration error

Webhook Processing

Receiving Webhooks

  1. Receive POST Request: Your endpoint receives the webhook payload
  2. Validate Signature (if implemented): Verify the webhook is from PayRetailers
  3. Check Idempotency: Use eventId to ensure you haven't processed this event
  4. Process Event: Update your system based on the event
  5. Respond 200 OK: Return success response quickly

Idempotency

Always use the eventId field to ensure idempotency. Store processed event IDs and check before processing:

# Example idempotency check
def process_webhook(payload):
    event_id = payload['eventId']
    
    # Check if already processed
    if is_event_processed(event_id):
        return 200  # Already processed, return success
    
    # Process event
    process_event(payload)
    
    # Mark as processed
    mark_event_processed(event_id)
    
    return 200

Response Requirements

Your webhook endpoint must:

  • Respond quickly: Return 200 OK within a few seconds
  • Process asynchronously: Do heavy processing in background jobs
  • Handle errors gracefully: Return appropriate HTTP status codes

Response Codes:

  • 200 OK: Webhook received and processed successfully
  • 400 Bad Request: Invalid payload (webhook will not be retried)
  • 500 Internal Server Error: Processing error (webhook will be retried)

Retry Logic

The WebhookSender service automatically retries failed webhooks:

  • Retries occur if your endpoint doesn't return 200 OK
  • Retry intervals increase exponentially
  • Maximum retry attempts are configurable
  • Failed webhooks are logged for investigation

Best Practices

Idempotency

  • Always check eventId before processing
  • Store processed event IDs
  • Handle duplicate events gracefully

Fast Response

  • Respond with 200 OK immediately
  • Process events asynchronously
  • Use background jobs for heavy processing

Error Handling

  • Log all webhook events
  • Handle errors gracefully
  • Return appropriate HTTP status codes
  • Monitor webhook delivery failures

Validation

  • Validate entityId exists in your system
  • Verify event structure
  • Check required fields

Security

  • Validate webhook signatures (if provided)
  • Use HTTPS for webhook URLs
  • Implement rate limiting
  • Monitor for suspicious activity

Monitoring

  • Log all webhook events
  • Track processing times
  • Monitor error rates
  • Alert on delivery failures

Example Webhook Handler

from flask import Flask, request, jsonify
import logging

app = Flask(__name__)
logger = logging.getLogger(__name__)

# Store processed event IDs (use Redis in production)
processed_events = set()

@app.route('/webhooks/subscriptions', methods=['POST'])
def handle_webhook():
    try:
        payload = request.json
        event_id = payload.get('eventId')
        event_type = payload.get('eventType')
        entity_id = payload.get('entityId')
        status = payload.get('status')
        
        # Idempotency check
        if event_id in processed_events:
            logger.info(f"Event {event_id} already processed")
            return jsonify({'status': 'ok'}), 200
        
        # Process event based on type
        if event_type == 'subscription_activation':
            handle_subscription_activation(payload)
        elif event_type == 'subscription.payment':
            handle_payment_event(payload)
        elif event_type == 'subscription_cancellation':
            handle_subscription_cancellation(payload)
        
        # Mark as processed
        processed_events.add(event_id)
        
        return jsonify({'status': 'ok'}), 200
        
    except Exception as e:
        logger.error(f"Error processing webhook: {e}")
        return jsonify({'error': 'Internal error'}), 500

def handle_subscription_activation(payload):
    subscription_id = payload['entityId']
    # Update subscription status in your system
    logger.info(f"Subscription {subscription_id} activated")

def handle_payment_event(payload):
    payment_id = payload['entityId']
    status = payload['status']
    
    if status == 'PAID':
        # Update payment status, send confirmation email, etc.
        logger.info(f"Payment {payment_id} paid")
    elif status == 'FAILED':
        # Handle failed payment, notify customer, etc.
        error_code = payload.get('errorCode')
        logger.warning(f"Payment {payment_id} failed: {error_code}")

def handle_subscription_cancellation(payload):
    subscription_id = payload['entityId']
    # Update subscription status, handle cancellation
    logger.info(f"Subscription {subscription_id} cancelled")

Next Steps