Skip to content

feat(core): Support for multiple outgoing webhooks added#7578

Closed
swetasharma03 wants to merge 4 commits intomainfrom
multiple-outgoing-webhooks
Closed

feat(core): Support for multiple outgoing webhooks added#7578
swetasharma03 wants to merge 4 commits intomainfrom
multiple-outgoing-webhooks

Conversation

@swetasharma03
Copy link
Contributor

@swetasharma03 swetasharma03 commented Mar 19, 2025

Type of Change

  • Bugfix
  • New feature
  • Enhancement
  • Refactoring
  • Dependency updates
  • Documentation
  • CI/CD

Description

In the current system, merchants can configure only one webhook endpoint per profile. This PR introduces support for configuring multiple webhook endpoints based on the event_type. These changes include modifications to the database schema and alterations in the webhook triggering logic.

In payments.rs (for other flows as well), instead of triggering a single webhook directly, a bulk_outgoing_webhook job is now created. When this job is executed, it triggers multiple webhooks. Additionally, for each webhook, a corresponding retry_job is created in the process_tracker, ensuring that any failed webhook deliveries can be retried.

Database Schema Changes

  1. Business Profile Schema Update:
    The webhook_details column in the business_profile table is being updated from a JSON object to an array of JSON objects. This will allow each business profile to store multiple webhook endpoints that can be associated with different event types.

  2. Merchant Account Schema Update:
    In addition to the business_profile table, the merchant_account table will also include a webhook_details field to store webhook configurations for the merchant. Similar to the changes in the business_profile table, this field will be updated to store an array of webhook configurations rather than a single configuration.

  3. How old data would work with new code?
    Suppose we have webhook_details filled as

"webhook_details": {
        "webhook_version": "1.0.1",
        "webhook_username": "ekart_retail",
        "webhook_password": "password_ekart@123",
        "webhook_url": "https://webhook.site",
        "payment_created_enabled": true,
        "payment_succeeded_enabled": true,
        "payment_failed_enabled": true,
       "multiple_webhooks_list": null
}

We have three functions which fetches webhook_details for outgoing_webhook_workflow.

  1. get_webhook_details_for_event_type(): Here if multiple_webhooks_list is null, a struct multipleWebhooksList is created with present details and it is used up in create_event_and_trigger_outgoing_webhook, assuming that the single url present will receive webhooks for all events.

  2. get_webhook_detail_by_webhook_endpoint_id(): If webhook_endpoint_idis None, it represents an older form of data, and hencewebhook_url` present is returned.

  3. get_idempotent_event_id(): Previous version of this fn formed id with form {primary_object_id}_{event_type}, now it is base encoded of {primary_object_id}_{event_type}_{webhook_endpoint_id}. In case webhook_endpoint_id passed is None, it refers to older form of data, then id generated would be base64 of {primary_object_id}_{event_type}.

  4. How already stored tasks stored in process_tracker work?
    If the task is already stored, it means trigger_outgoing_webhook_bulk_workflow() will not be called for that outgoing_webhook.
    For trigger_outgoing_webhook_retry_workflow(), granular description of functions used here are described above for get_idempotent_event_id, get_webhook_detail_by_webhook_endpoint_id

The update ensures backward compatibility, as existing records may still use the old schema (storing a single webhook configuration).

Additional Changes

  • This PR modifies the API contract
  • This PR modifies the database schema
  • This PR modifies application configuration/environment variables

Motivation and Context

How did you test it?

  1. Merchant account create (with three webhook endpoints configured, two endpoints are correct and one is wrong to test automatic retry)
curl --location 'http://localhost:8080/accounts' \
--header 'Content-Type: application/json' \
--header 'Accept: application/json' \
--header 'api-key: test_admin' \
--data-raw '{
    "merchant_id": "merchant_1748463901",
    "locker_id": "m0010",
    "merchant_name": "NewAge Retailer",
    "merchant_details": {
        "primary_contact_person": "John Test",
        "primary_email": "JohnTest@test.com",
        "primary_phone": "sunt laborum",
        "secondary_contact_person": "John Test2",
        "secondary_email": "JohnTest2@test.com",
        "secondary_phone": "cillum do dolor id",
        "website": "https://www.example.com",
        "about_business": "Online Retail with a wide selection of organic products for North America",
        "address": {
            "line1": "1467",
            "line2": "Harrison Street",
            "line3": "Harrison Street",
            "city": "San Fransico",
            "state": "California",
            "zip": "94122",
            "country": "US",
            "first_name": "john",
            "last_name": "Doe"
        }
    },
    "return_url": "https://google.com/success",
    "webhook_details": {  
        "multiple_webhooks_list": [
            {
                "webhook_url": "https://webhook.site/6f2bb1bd-dbb9-432c-b4a2-c76b801f2609",
                "events": [
                    "payment_succeeded"
                ]
            },
            {
                "webhook_url": "https://webhook.site/381773c7-c3bd-406e-bda3-dfc5d2fea979",
                "events": [
                    "payment_succeeded",
                    "refund_failed"
                ]
            },
            {
                "webhook_url": "https://webhook.site/automatic-retry-test",
                "events": [
                    "refund_failed",
                    "payment_succeeded"
                ]
            }
        ]
    },
    "sub_merchants_enabled": false,
    "parent_merchant_id": "merchant_123",
    "metadata": {
        "city": "NY",
        "unit": "245"
    },
    "primary_business_details": [
        {
            "country": "US",
            "business": "default"
        }
    ]
}'

Response

{
    "merchant_id": "merchant_1748463854",
    "merchant_name": "NewAge Retailer",
    "return_url": "https://google.com/success",
    "enable_payment_response_hash": true,
    "payment_response_hash_key": "03qU2TjCh216DXE1GU6oJpdzxQXmT80KAU8hQn74RZZEvRaRXO4Cmckj49PLX6m4",
    "redirect_to_merchant_with_http_post": false,
    "merchant_details": {
        "primary_contact_person": "John Test",
        "primary_phone": "sunt laborum",
        "primary_email": "JohnTest@test.com",
        "secondary_contact_person": "John Test2",
        "secondary_phone": "cillum do dolor id",
        "secondary_email": "JohnTest2@test.com",
        "website": "https://www.example.com",
        "about_business": "Online Retail with a wide selection of organic products for North America",
        "address": {
            "city": "San Fransico",
            "country": "US",
            "line1": "1467",
            "line2": "Harrison Street",
            "line3": "Harrison Street",
            "zip": "94122",
            "state": "California",
            "first_name": "john",
            "last_name": "Doe"
        }
    },
    "webhook_details": {
        "webhook_version": null,
        "webhook_username": null,
        "webhook_password": null,
        "webhook_url": null,
        "payment_created_enabled": null,
        "payment_succeeded_enabled": null,
        "payment_failed_enabled": null,
        "multiple_webhooks_list": [
            {
                "webhook_endpoint_id": "whe_qolIdtZFQfWNmUg2i7X2",
                "webhook_url": "https://webhook.site/6f2bb1bd-dbb9-432c-b4a2-c76b801f2609",
                "events": [
                    "payment_succeeded"
                ],
                "status": "active"
            },
            {
                "webhook_endpoint_id": "whe_9ugRQr1n3vO4ehmJ80Kx",
                "webhook_url": "https://webhook.site/381773c7-c3bd-406e-bda3-dfc5d2fea979",
                "events": [
                    "payment_succeeded",
                    "refund_failed"
                ],
                "status": "active"
            },
            {
                "webhook_endpoint_id": "whe_3zAXAGH81VGY6Cw8lWLK",
                "webhook_url": "https://webhook.site/automatic-retry-test",
                "events": [
                    "payment_succeeded",
                    "refund_failed"
                ],
                "status": "active"
            }
        ]
    },
    "payout_routing_algorithm": null,
    "sub_merchants_enabled": false,
    "parent_merchant_id": null,
    "publishable_key": "pk_dev_9ac4425918a24870bb5cd87ebf9b8e76",
    "metadata": {
        "city": "NY",
        "unit": "245",
        "compatible_connector": null
    },
    "locker_id": "m0010",
    "primary_business_details": [
        {
            "country": "US",
            "business": "default"
        }
    ],
    "frm_routing_algorithm": null,
    "organization_id": "org_aeXhfXk1juyUMg9C4cLp",
    "is_recon_enabled": false,
    "default_profile": "pro_UfVajfSqskx9FjWfP5cP",
    "recon_status": "not_requested",
    "pm_collect_link_config": null,
    "product_type": "orchestration"
}
  1. Create payment
curl --location 'http://localhost:8080/payments' \
--header 'Content-Type: application/json' \
--header 'Accept: application/json' \
--header 'api-key: dev_uCH6m5Obz9TWXaGyrw9HJCufkrvHewRUy0jiaD232wiwEE0UUztSwTdwPvNBKqam' \
--data-raw '{
    "amount": 6540,
    "currency": "USD",
    "amount_to_capture": 6540,
    "confirm": true,
    "profile_id": "pro_UfVajfSqskx9FjWfP5cP",
    "capture_method": "automatic",
    "capture_on": "2022-09-10T10:11:12Z",
    "authentication_type": "no_three_ds",
    "setup_future_usage": "on_session", 
    "customer": {
        "id": "customer123",
        "name": "John Doe",
        "email": "customer@gmail.com",
        "phone": "9999999999",
        "phone_country_code": "+1"
    },
    "customer_id": "customer123",
    "phone_country_code": "+1",
    "routing": { 
        "type": "single",
        "data": "stripe"
    },
    "description": "Its my first payment request",
    "return_url": "https://google.com",
    "payment_method": "card",
    "payment_method_type": "credit",
    "payment_method_data": { 
        "card": {
            "card_number": "4111111111111111",
            "card_exp_month": "03",
            "card_exp_year": "2030",
            "card_cvc": "737"
        }
    },
    
    "billing": {
        "address": {
            "line1": "1467",
            "line2": "Harrison Street",
            "line3": "Harrison Street",
            "city": "San Fransico",
            "state": "California",
            "zip": "94122",
            "country": "US",
            "first_name": "joseph",
            "last_name": "Doe"
        },
        "phone": {
            "number": "8056594427",
            "country_code": "+91"
        },
        "email": "guest@example.com"
    },
    "shipping": {
        "address": {
            "line1": "1467",
            "line2": "Harrison Street",
            "line3": "Harrison Street",
            "city": "San Fransico",
            "state": "California",
            "zip": "94122",
            "country": "US",
            "first_name": "joseph",
            "last_name": "Doe"
        },
        "phone": {
            "number": "8056594427",
            "country_code": "+91"
        },
        "email": "guest@example.com"
    },
    "statement_descriptor_name": "joseph",
    "statement_descriptor_suffix": "JS",
    "order_details": [
        {
            "product_name": "Apple iphone 15",
            "quantity": 1,
            "amount": 6540,
            "account_name": "transaction_processing"
        }
    ],
    "metadata": {
        "udf1": "value1",
        "new_customer": "true",
        "login_date": "2019-09-10T10:11:12Z"
    },
    "browser_info": {
        "user_agent": "Mozilla\/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/70.0.3538.110 Safari\/537.36",
        "accept_header": "text\/html,application\/xhtml+xml,application\/xml;q=0.9,image\/webp,image\/apng,*\/*;q=0.8",
        "language": "nl-NL",
        "color_depth": 24,
        "screen_height": 723,
        "screen_width": 1536,
        "time_zone": 0,
        "java_enabled": true,
        "java_script_enabled": true,
        "ip_address": "128.0.0.1"
    },
    "customer_acceptance": {
        "acceptance_type": "offline",
        "accepted_at": "1963-05-03T04:07:52.723Z",
        "online": {
            "ip_address": "125.0.0.1",
            "user_agent": "amet irure esse"
        }
    },
    
    "connector_metadata": {
        "noon": {
            "order_category": "pay"
        }
    },
    "payment_link": false,
    "payment_link_config": {
        "theme": "",
        "logo": "",
        "seller_name": "",
        "sdk_layout": "",
        "display_sdk_only": false,
        "enabled_saved_payment_method": false
    },
    "payment_type": "normal", 
    "request_incremental_authorization": false,
    "merchant_order_reference_id": "test_ord",
    "session_expiry": 900   
}'

Response
Payment created with id pay_6vacj5lnQX9BKSLeXYUy

{
    "payment_id": "pay_6vacj5lnQX9BKSLeXYUy",
    "merchant_id": "merchant_1748463854",
    "status": "succeeded",
    "amount": 6540,
    "net_amount": 6540,
    "shipping_cost": null,
    "amount_capturable": 0,
    "amount_received": 6540,
    "connector": "adyen",
    "client_secret": "pay_6vacj5lnQX9BKSLeXYUy_secret_GxdazMB1wOxZdf6UJ11l",
    "created": "2025-05-28T20:24:28.821Z",
    "currency": "USD",
    "customer_id": "customer123",
    "customer": {
        "id": "customer123",
        "name": "John Doe",
        "email": "customer@gmail.com",
        "phone": "9999999999",
        "phone_country_code": "+1"
    },
    "description": "Its my first payment request",
    "refunds": null,
    "disputes": null,
    "mandate_id": null,
    "mandate_data": null,
    "setup_future_usage": "on_session",
    "off_session": null,
    "capture_on": null,
    "capture_method": "automatic",
    "payment_method": "card",
    "payment_method_data": {
        "card": {
            "last4": "1111",
            "card_type": null,
            "card_network": null,
            "card_issuer": null,
            "card_issuing_country": null,
            "card_isin": "411111",
            "card_extended_bin": null,
            "card_exp_month": "03",
            "card_exp_year": "2030",
            "card_holder_name": null,
            "payment_checks": null,
            "authentication_data": null
        },
        "billing": null
    },
    "payment_token": null,
    "shipping": {
        "address": {
            "city": "San Fransico",
            "country": "US",
            "line1": "1467",
            "line2": "Harrison Street",
            "line3": "Harrison Street",
            "zip": "94122",
            "state": "California",
            "first_name": "joseph",
            "last_name": "Doe"
        },
        "phone": {
            "number": "8056594427",
            "country_code": "+91"
        },
        "email": "guest@example.com"
    },
    "billing": {
        "address": {
            "city": "San Fransico",
            "country": "US",
            "line1": "1467",
            "line2": "Harrison Street",
            "line3": "Harrison Street",
            "zip": "94122",
            "state": "California",
            "first_name": "joseph",
            "last_name": "Doe"
        },
        "phone": {
            "number": "8056594427",
            "country_code": "+91"
        },
        "email": "guest@example.com"
    },
    "order_details": [
        {
            "brand": null,
            "amount": 6540,
            "category": null,
            "quantity": 1,
            "tax_rate": null,
            "product_id": null,
            "product_name": "Apple iphone 15",
            "product_type": null,
            "sub_category": null,
            "product_img_link": null,
            "product_tax_code": null,
            "total_tax_amount": null,
            "requires_shipping": null
        }
    ],
    "email": "customer@gmail.com",
    "name": "John Doe",
    "phone": "9999999999",
    "return_url": "https://google.com/",
    "authentication_type": "no_three_ds",
    "statement_descriptor_name": "joseph",
    "statement_descriptor_suffix": "JS",
    "next_action": null,
    "cancellation_reason": null,
    "error_code": null,
    "error_message": null,
    "unified_code": null,
    "unified_message": null,
    "payment_experience": null,
    "payment_method_type": "credit",
    "connector_label": null,
    "business_country": null,
    "business_label": "default",
    "business_sub_label": null,
    "allowed_payment_method_types": null,
    "ephemeral_key": {
        "customer_id": "customer123",
        "created_at": 1748463868,
        "expires": 1748467468,
        "secret": "epk_b857940df0a245ef8417f4423de61c14"
    },
    "manual_retry_allowed": false,
    "connector_transaction_id": "GQ7N5P2BQCPFMZ65",
    "frm_message": null,
    "metadata": {
        "udf1": "value1",
        "login_date": "2019-09-10T10:11:12Z",
        "new_customer": "true"
    },
    "connector_metadata": {
        "apple_pay": null,
        "airwallex": null,
        "noon": {
            "order_category": "pay"
        },
        "braintree": null,
        "adyen": null
    },
    "feature_metadata": null,
    "reference_id": "pay_6vacj5lnQX9BKSLeXYUy_1",
    "payment_link": null,
    "profile_id": "pro_UfVajfSqskx9FjWfP5cP",
    "surcharge_details": null,
    "attempt_count": 1,
    "merchant_decision": null,
    "merchant_connector_id": "mca_FzqxnIqNjwC9MRSHnl9p",
    "incremental_authorization_allowed": false,
    "authorization_count": null,
    "incremental_authorizations": null,
    "external_authentication_details": null,
    "external_3ds_authentication_attempted": false,
    "expires_on": "2025-05-28T20:39:28.821Z",
    "fingerprint": null,
    "browser_info": {
        "language": "nl-NL",
        "time_zone": 0,
        "ip_address": "128.0.0.1",
        "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.110 Safari/537.36",
        "color_depth": 24,
        "java_enabled": true,
        "screen_width": 1536,
        "accept_header": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8",
        "screen_height": 723,
        "java_script_enabled": true
    },
    "payment_method_id": null,
    "payment_method_status": null,
    "updated": "2025-05-28T20:24:31.305Z",
    "split_payments": null,
    "frm_metadata": null,
    "extended_authorization_applied": null,
    "capture_before": null,
    "merchant_order_reference_id": "test_ord",
    "order_tax_amount": null,
    "connector_mandate_id": null,
    "card_discovery": "manual",
    "force_3ds_challenge": false,
    "force_3ds_challenge_trigger": false,
    "issuer_error_code": null,
    "issuer_error_message": null
}

After doing a payment with status succeeded, bulk webhook added to process_tracker
image

This bulk task, adds all the multiple outgoing webhooks (three for the merchant created) in process_tracker, two outgoing webhooks notified, one is pending as the endpoint was wrong
image

Three events created initially for corresponding tasks of process tracker above. One notification failed, so there is one more entry in events table for automatic retry.
Events created by process_tracker:
image

Two webhooks received in different endpoints which were correctly configured
Screenshot 2025-05-29 at 1 56 00 AM

Screenshot 2025-05-29 at 1 56 17 AM
  1. Manual retry to first webhook endpoint
curl --location --request POST 'http://localhost:8080/events/merchant_1748463854/evt_01971892575c7c5395780fbd740e6b00/retry' \
--header 'api-key: test_admin'

Response

{
    "event_id": "evt_019718a1f5f170c3954a3fc958337e51",
    "merchant_id": "merchant_1748463854",
    "profile_id": "pro_UfVajfSqskx9FjWfP5cP",
    "webhook_endpoint_id": "whe_qolIdtZFQfWNmUg2i7X2",
    "object_id": "pay_6vacj5lnQX9BKSLeXYUy",
    "event_type": "payment_succeeded",
    "event_class": "payments",
    "is_delivery_successful": false,
    "initial_attempt_id": "evt_01971892575c7c5395780fbd740e6b00",
    "created": "2025-05-28T20:42:36.914Z",
    "request": {
        "body": "{\"merchant_id\":\"merchant_1748463854\",\"event_id\":\"evt_01971892575c7c5395780fbd740e6b00\",\"event_type\":\"payment_succeeded\",\"content\":{\"type\":\"payment_details\",\"object\":{\"payment_id\":\"pay_6vacj5lnQX9BKSLeXYUy\",\"merchant_id\":\"merchant_1748463854\",\"status\":\"succeeded\",\"amount\":6540,\"net_amount\":6540,\"shipping_cost\":null,\"amount_capturable\":0,\"amount_received\":6540,\"connector\":\"adyen\",\"client_secret\":\"pay_6vacj5lnQX9BKSLeXYUy_secret_GxdazMB1wOxZdf6UJ11l\",\"created\":\"2025-05-28T20:24:28.821Z\",\"currency\":\"USD\",\"customer_id\":\"customer123\",\"customer\":{\"id\":\"customer123\",\"name\":\"John Doe\",\"email\":\"customer@gmail.com\",\"phone\":\"9999999999\",\"phone_country_code\":\"+1\"},\"description\":\"Its my first payment request\",\"refunds\":null,\"disputes\":null,\"mandate_id\":null,\"mandate_data\":null,\"setup_future_usage\":\"on_session\",\"off_session\":null,\"capture_on\":null,\"capture_method\":\"automatic\",\"payment_method\":\"card\",\"payment_method_data\":{\"card\":{\"last4\":\"1111\",\"card_type\":null,\"card_network\":null,\"card_issuer\":null,\"card_issuing_country\":null,\"card_isin\":\"411111\",\"card_extended_bin\":null,\"card_exp_month\":\"03\",\"card_exp_year\":\"2030\",\"card_holder_name\":null,\"payment_checks\":null,\"authentication_data\":null},\"billing\":null},\"payment_token\":null,\"shipping\":{\"address\":{\"city\":\"San Fransico\",\"country\":\"US\",\"line1\":\"1467\",\"line2\":\"Harrison Street\",\"line3\":\"Harrison Street\",\"zip\":\"94122\",\"state\":\"California\",\"first_name\":\"joseph\",\"last_name\":\"Doe\"},\"phone\":{\"number\":\"8056594427\",\"country_code\":\"+91\"},\"email\":\"guest@example.com\"},\"billing\":{\"address\":{\"city\":\"San Fransico\",\"country\":\"US\",\"line1\":\"1467\",\"line2\":\"Harrison Street\",\"line3\":\"Harrison Street\",\"zip\":\"94122\",\"state\":\"California\",\"first_name\":\"joseph\",\"last_name\":\"Doe\"},\"phone\":{\"number\":\"8056594427\",\"country_code\":\"+91\"},\"email\":\"guest@example.com\"},\"order_details\":[{\"brand\":null,\"amount\":6540,\"category\":null,\"quantity\":1,\"tax_rate\":null,\"product_id\":null,\"product_name\":\"Apple iphone 15\",\"product_type\":null,\"sub_category\":null,\"product_img_link\":null,\"product_tax_code\":null,\"total_tax_amount\":null,\"requires_shipping\":null}],\"email\":\"customer@gmail.com\",\"name\":\"John Doe\",\"phone\":\"9999999999\",\"return_url\":\"https://google.com/\",\"authentication_type\":\"no_three_ds\",\"statement_descriptor_name\":\"joseph\",\"statement_descriptor_suffix\":\"JS\",\"next_action\":null,\"cancellation_reason\":null,\"error_code\":null,\"error_message\":null,\"unified_code\":null,\"unified_message\":null,\"payment_experience\":null,\"payment_method_type\":\"credit\",\"connector_label\":null,\"business_country\":null,\"business_label\":\"default\",\"business_sub_label\":null,\"allowed_payment_method_types\":null,\"ephemeral_key\":null,\"manual_retry_allowed\":false,\"connector_transaction_id\":\"GQ7N5P2BQCPFMZ65\",\"frm_message\":null,\"metadata\":{\"udf1\":\"value1\",\"login_date\":\"2019-09-10T10:11:12Z\",\"new_customer\":\"true\"},\"connector_metadata\":{\"apple_pay\":null,\"airwallex\":null,\"noon\":{\"order_category\":\"pay\"},\"braintree\":null,\"adyen\":null},\"feature_metadata\":null,\"reference_id\":\"pay_6vacj5lnQX9BKSLeXYUy_1\",\"payment_link\":null,\"profile_id\":\"pro_UfVajfSqskx9FjWfP5cP\",\"surcharge_details\":null,\"attempt_count\":1,\"merchant_decision\":null,\"merchant_connector_id\":\"mca_FzqxnIqNjwC9MRSHnl9p\",\"incremental_authorization_allowed\":false,\"authorization_count\":null,\"incremental_authorizations\":null,\"external_authentication_details\":null,\"external_3ds_authentication_attempted\":false,\"expires_on\":\"2025-05-28T20:39:28.821Z\",\"fingerprint\":null,\"browser_info\":{\"language\":\"nl-NL\",\"time_zone\":0,\"ip_address\":\"128.0.0.1\",\"user_agent\":\"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.110 Safari/537.36\",\"color_depth\":24,\"java_enabled\":true,\"screen_width\":1536,\"accept_header\":\"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8\",\"screen_height\":723,\"java_script_enabled\":true},\"payment_method_id\":\"pm_x8ICuV0PsnuaK8ERCkxH\",\"payment_method_status\":\"active\",\"updated\":\"2025-05-28T20:24:31.305Z\",\"split_payments\":null,\"frm_metadata\":null,\"extended_authorization_applied\":null,\"capture_before\":null,\"merchant_order_reference_id\":\"test_ord\",\"order_tax_amount\":null,\"connector_mandate_id\":null,\"card_discovery\":\"manual\",\"force_3ds_challenge\":false,\"force_3ds_challenge_trigger\":false,\"issuer_error_code\":null,\"issuer_error_message\":null}},\"timestamp\":\"2025-05-28T20:25:33.276Z\"}",
        "headers": [
            [
                "content-type",
                "application/json"
            ],
            [
                "user-agent",
                "Hyperswitch-Backend-Server"
            ],
            [
                "X-Webhook-Signature-512",
                "b81594a37e46cd58c24c04d2b96aaa658c82d0007f3c967c1f62d8bb9d72f1d418bec651900fc0076816720f2dc0d6bb93cbd92915b00b68d6c6eca7772ca84a"
            ]
        ]
    },
    "response": {
        "body": "This URL has no default content configured. <a href=\"https://webhook.site/#!/edit/6f2bb1bd-dbb9-432c-b4a2-c76b801f2609\">Change response in Webhook.site</a>.",
        "headers": [
            [
                "server",
                "nginx"
            ],
            [
                "content-type",
                "text/html; charset=UTF-8"
            ],
            [
                "transfer-encoding",
                "chunked"
            ],
            [
                "x-request-id",
                "fd416e97-ea7b-4cc2-9678-ee7fcd68849e"
            ],
            [
                "x-token-id",
                "6f2bb1bd-dbb9-432c-b4a2-c76b801f2609"
            ],
            [
                "cache-control",
                "no-cache, private"
            ],
            [
                "date",
                "Wed, 28 May 2025 20:42:37 GMT"
            ]
        ],
        "status_code": 200,
        "error_message": null
    },
    "delivery_attempt": "manual_retry"
}

Webhook received for manual retry in first endpoint
Screenshot 2025-05-29 at 2 13 59 AM

  1. Manual retry to second webhook endpoint
curl --location --request POST 'http://localhost:8080/events/merchant_1748463854/evt_01971892575c7c5395780fc76469ec8c/retry' \
--header 'api-key: test_admin'

Response

{
    "event_id": "evt_01971894d5157c22a1e1e148e6518bda",
    "merchant_id": "merchant_1748463854",
    "profile_id": "pro_UfVajfSqskx9FjWfP5cP",
    "webhook_endpoint_id": "whe_9ugRQr1n3vO4ehmJ80Kx",
    "object_id": "pay_6vacj5lnQX9BKSLeXYUy",
    "event_type": "payment_succeeded",
    "event_class": "payments",
    "is_delivery_successful": false,
    "initial_attempt_id": "evt_01971892575c7c5395780fc76469ec8c",
    "created": "2025-05-28T20:28:16.535Z",
    "request": {
        "body": "{\"merchant_id\":\"merchant_1748463854\",\"event_id\":\"evt_01971892575c7c5395780fc76469ec8c\",\"event_type\":\"payment_succeeded\",\"content\":{\"type\":\"payment_details\",\"object\":{\"payment_id\":\"pay_6vacj5lnQX9BKSLeXYUy\",\"merchant_id\":\"merchant_1748463854\",\"status\":\"succeeded\",\"amount\":6540,\"net_amount\":6540,\"shipping_cost\":null,\"amount_capturable\":0,\"amount_received\":6540,\"connector\":\"adyen\",\"client_secret\":\"pay_6vacj5lnQX9BKSLeXYUy_secret_GxdazMB1wOxZdf6UJ11l\",\"created\":\"2025-05-28T20:24:28.821Z\",\"currency\":\"USD\",\"customer_id\":\"customer123\",\"customer\":{\"id\":\"customer123\",\"name\":\"John Doe\",\"email\":\"customer@gmail.com\",\"phone\":\"9999999999\",\"phone_country_code\":\"+1\"},\"description\":\"Its my first payment request\",\"refunds\":null,\"disputes\":null,\"mandate_id\":null,\"mandate_data\":null,\"setup_future_usage\":\"on_session\",\"off_session\":null,\"capture_on\":null,\"capture_method\":\"automatic\",\"payment_method\":\"card\",\"payment_method_data\":{\"card\":{\"last4\":\"1111\",\"card_type\":null,\"card_network\":null,\"card_issuer\":null,\"card_issuing_country\":null,\"card_isin\":\"411111\",\"card_extended_bin\":null,\"card_exp_month\":\"03\",\"card_exp_year\":\"2030\",\"card_holder_name\":null,\"payment_checks\":null,\"authentication_data\":null},\"billing\":null},\"payment_token\":null,\"shipping\":{\"address\":{\"city\":\"San Fransico\",\"country\":\"US\",\"line1\":\"1467\",\"line2\":\"Harrison Street\",\"line3\":\"Harrison Street\",\"zip\":\"94122\",\"state\":\"California\",\"first_name\":\"joseph\",\"last_name\":\"Doe\"},\"phone\":{\"number\":\"8056594427\",\"country_code\":\"+91\"},\"email\":\"guest@example.com\"},\"billing\":{\"address\":{\"city\":\"San Fransico\",\"country\":\"US\",\"line1\":\"1467\",\"line2\":\"Harrison Street\",\"line3\":\"Harrison Street\",\"zip\":\"94122\",\"state\":\"California\",\"first_name\":\"joseph\",\"last_name\":\"Doe\"},\"phone\":{\"number\":\"8056594427\",\"country_code\":\"+91\"},\"email\":\"guest@example.com\"},\"order_details\":[{\"brand\":null,\"amount\":6540,\"category\":null,\"quantity\":1,\"tax_rate\":null,\"product_id\":null,\"product_name\":\"Apple iphone 15\",\"product_type\":null,\"sub_category\":null,\"product_img_link\":null,\"product_tax_code\":null,\"total_tax_amount\":null,\"requires_shipping\":null}],\"email\":\"customer@gmail.com\",\"name\":\"John Doe\",\"phone\":\"9999999999\",\"return_url\":\"https://google.com/\",\"authentication_type\":\"no_three_ds\",\"statement_descriptor_name\":\"joseph\",\"statement_descriptor_suffix\":\"JS\",\"next_action\":null,\"cancellation_reason\":null,\"error_code\":null,\"error_message\":null,\"unified_code\":null,\"unified_message\":null,\"payment_experience\":null,\"payment_method_type\":\"credit\",\"connector_label\":null,\"business_country\":null,\"business_label\":\"default\",\"business_sub_label\":null,\"allowed_payment_method_types\":null,\"ephemeral_key\":null,\"manual_retry_allowed\":false,\"connector_transaction_id\":\"GQ7N5P2BQCPFMZ65\",\"frm_message\":null,\"metadata\":{\"udf1\":\"value1\",\"login_date\":\"2019-09-10T10:11:12Z\",\"new_customer\":\"true\"},\"connector_metadata\":{\"apple_pay\":null,\"airwallex\":null,\"noon\":{\"order_category\":\"pay\"},\"braintree\":null,\"adyen\":null},\"feature_metadata\":null,\"reference_id\":\"pay_6vacj5lnQX9BKSLeXYUy_1\",\"payment_link\":null,\"profile_id\":\"pro_UfVajfSqskx9FjWfP5cP\",\"surcharge_details\":null,\"attempt_count\":1,\"merchant_decision\":null,\"merchant_connector_id\":\"mca_FzqxnIqNjwC9MRSHnl9p\",\"incremental_authorization_allowed\":false,\"authorization_count\":null,\"incremental_authorizations\":null,\"external_authentication_details\":null,\"external_3ds_authentication_attempted\":false,\"expires_on\":\"2025-05-28T20:39:28.821Z\",\"fingerprint\":null,\"browser_info\":{\"language\":\"nl-NL\",\"time_zone\":0,\"ip_address\":\"128.0.0.1\",\"user_agent\":\"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.110 Safari/537.36\",\"color_depth\":24,\"java_enabled\":true,\"screen_width\":1536,\"accept_header\":\"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8\",\"screen_height\":723,\"java_script_enabled\":true},\"payment_method_id\":\"pm_x8ICuV0PsnuaK8ERCkxH\",\"payment_method_status\":\"active\",\"updated\":\"2025-05-28T20:24:31.305Z\",\"split_payments\":null,\"frm_metadata\":null,\"extended_authorization_applied\":null,\"capture_before\":null,\"merchant_order_reference_id\":\"test_ord\",\"order_tax_amount\":null,\"connector_mandate_id\":null,\"card_discovery\":\"manual\",\"force_3ds_challenge\":false,\"force_3ds_challenge_trigger\":false,\"issuer_error_code\":null,\"issuer_error_message\":null}},\"timestamp\":\"2025-05-28T20:25:33.276Z\"}",
        "headers": [
            [
                "content-type",
                "application/json"
            ],
            [
                "user-agent",
                "Hyperswitch-Backend-Server"
            ],
            [
                "X-Webhook-Signature-512",
                "d63a92f145268741a83bf41db24769f401a000254906391bd581ca8b4be99734e5731711b693e8cfc44703014dfcc60fd73105f593ee7dbffe7687ee07134d4e"
            ]
        ]
    },
    "response": {
        "body": "This URL has no default content configured. <a href=\"https://webhook.site/#!/edit/381773c7-c3bd-406e-bda3-dfc5d2fea979\">Change response in Webhook.site</a>.",
        "headers": [
            [
                "server",
                "nginx"
            ],
            [
                "content-type",
                "text/html; charset=UTF-8"
            ],
            [
                "transfer-encoding",
                "chunked"
            ],
            [
                "x-request-id",
                "18b3a013-caf2-4667-ab91-29cffa99ce86"
            ],
            [
                "x-token-id",
                "381773c7-c3bd-406e-bda3-dfc5d2fea979"
            ],
            [
                "cache-control",
                "no-cache, private"
            ],
            [
                "date",
                "Wed, 28 May 2025 20:28:17 GMT"
            ]
        ],
        "status_code": 200,
        "error_message": null
    },
    "delivery_attempt": "manual_retry"
}

Webhook received for manual retry in the second endpoint
Screenshot 2025-05-29 at 1 58 28 AM

  1. Updating business profile
curl --location 'http://localhost:8080/account/merchant_1742455203/business_profile/pro_P4LiydzsnrV6Sw6tKjie' \
--header 'Content-Type: application/json' \
--header 'api-key: test_admin' \
--data '{
    "profile_name": "Update",
    "return_url": "https://google.com/success",
    "enable_payment_response_hash": true,
    "redirect_to_merchant_with_http_post": false,
    "webhook_details": {
       "webhook_version": "1.0.1",
       "webhook_username": "ekart_retail",
       "webhook_password": "password_ekart@123",
       "webhook_url": "https://webhook.site/04e616b8-b2c1-4a12-968f-7546ef0bb7fd",
       "payment_created_enabled": true,
       "payment_succeeded_enabled": true,
       "payment_failed_enabled": true,
       "multiple_webhooks_list": [
           {
               "webhook_endpoint_id": "web_LxSxKD1m6c2bUfODoWLV",
               "webhook_url": "https://webhook.site/04e616b8-b2c1-4a12-968f-7546ef0bb7fd",
               "events": [
                   "payment_authorized",
                   "payment_succeeded",
               ],
               "status": "active"
           },
        ]
   },
    "metadata": null,
    "intent_fulfillment_time": 900,
    "frm_routing_algorithm": null,
    "payout_routing_algorithm": null,
    "applepay_verified_domains": null,
    "session_expiry": 900,
    "payment_link_config": null,
    "authentication_connector_details": null,
    "use_billing_as_payment_method_billing": true,
    "collect_shipping_details_from_wallet_connector": false,
    "collect_billing_details_from_wallet_connector": false,
    "is_connector_agnostic_mit_enabled": false,
    "payout_link_config": null,
    "outgoing_webhook_custom_http_headers": null
}'

Response

{
    "merchant_id": "merchant_1742455203",
    "profile_id": "pro_P4LiydzsnrV6Sw6tKjie",
    "profile_name": "Update",
    "return_url": "https://google.com/success",
    "enable_payment_response_hash": true,
    "payment_response_hash_key": "7aRicO9KmdjjlJDuOkBAopiLwDSEd3gGlI9axVuLWJuyrzXVFvEAwwqNieVnpItV",
    "redirect_to_merchant_with_http_post": false,
    "webhook_details": {
       "webhook_version": "1.0.1",
       "webhook_username": "ekart_retail",
       "webhook_password": "password_ekart@123",
       "webhook_url": "https://webhook.site/04e616b8-b2c1-4a12-968f-7546ef0bb7fd",
       "payment_created_enabled": true,
       "payment_succeeded_enabled": true,
       "payment_failed_enabled": true,
       "multiple_webhooks_list": [
           {
               "webhook_endpoint_id": "web_LxSxKD1m6c2bUfODoWLV",
               "webhook_url": "https://webhook.site/04e616b8-b2c1-4a12-968f-7546ef0bb7fd",
               "events": [
                   "payment_authorized",
                   "payment_succeeded",
               ],
               "status": "active"
           },
        ]
   },
    "metadata": null,
    "routing_algorithm": null,
    "intent_fulfillment_time": 900,
    "frm_routing_algorithm": null,
    "payout_routing_algorithm": null,
    "applepay_verified_domains": null,
    "session_expiry": 900,
    "payment_link_config": null,
    "authentication_connector_details": null,
    "use_billing_as_payment_method_billing": true,
    "extended_card_info_config": null,
    "collect_shipping_details_from_wallet_connector": false,
    "collect_billing_details_from_wallet_connector": false,
    "always_collect_shipping_details_from_wallet_connector": false,
    "always_collect_billing_details_from_wallet_connector": false,
    "is_connector_agnostic_mit_enabled": false,
    "payout_link_config": null,
    "outgoing_webhook_custom_http_headers": null,
    "tax_connector_id": null,
    "is_tax_connector_enabled": false,
    "is_network_tokenization_enabled": false,
    "is_auto_retries_enabled": false,
    "max_auto_retries_enabled": null,
    "always_request_extended_authorization": null,
    "is_click_to_pay_enabled": false,
    "authentication_product_ids": null,
    "card_testing_guard_config": {
        "card_ip_blocking_status": "disabled",
        "card_ip_blocking_threshold": 3,
        "guest_user_card_blocking_status": "disabled",
        "guest_user_card_blocking_threshold": 10,
        "customer_id_blocking_status": "disabled",
        "customer_id_blocking_threshold": 5,
        "card_testing_guard_expiry": 3600
    },
    "is_clear_pan_retries_enabled": false
}
  1. Fetching profile
{
    "merchant_id": "merchant_1742455203",
    "profile_id": "pro_P4LiydzsnrV6Sw6tKjie",
    "profile_name": "Update",
    "return_url": "https://google.com/success",
    "enable_payment_response_hash": true,
    "payment_response_hash_key": "7aRicO9KmdjjlJDuOkBAopiLwDSEd3gGlI9axVuLWJuyrzXVFvEAwwqNieVnpItV",
    "redirect_to_merchant_with_http_post": false,
     "webhook_details": {
       "webhook_version": "1.0.1",
       "webhook_username": "ekart_retail",
       "webhook_password": "password_ekart@123",
       "webhook_url": "https://webhook.site/04e616b8-b2c1-4a12-968f-7546ef0bb7fd",
       "payment_created_enabled": true,
       "payment_succeeded_enabled": true,
       "payment_failed_enabled": true,
       "multiple_webhooks_list": [
           {
               "webhook_endpoint_id": "web_LxSxKD1m6c2bUfODoWLV",
               "webhook_url": "https://webhook.site/04e616b8-b2c1-4a12-968f-7546ef0bb7fd",
               "events": [
                   "payment_authorized",
                   "payment_succeeded",
               ],
               "status": "active"
           },
        ]
   },
    "metadata": null,
    "routing_algorithm": null,
    "intent_fulfillment_time": 900,
    "frm_routing_algorithm": null,
    "payout_routing_algorithm": null,
    "applepay_verified_domains": null,
    "session_expiry": 900,
    "payment_link_config": null,
    "authentication_connector_details": null,
    "use_billing_as_payment_method_billing": true,
    "extended_card_info_config": null,
    "collect_shipping_details_from_wallet_connector": false,
    "collect_billing_details_from_wallet_connector": false,
    "always_collect_shipping_details_from_wallet_connector": false,
    "always_collect_billing_details_from_wallet_connector": false,
    "is_connector_agnostic_mit_enabled": false,
    "payout_link_config": null,
    "outgoing_webhook_custom_http_headers": null,
    "tax_connector_id": null,
    "is_tax_connector_enabled": false,
    "is_network_tokenization_enabled": false,
    "is_auto_retries_enabled": false,
    "max_auto_retries_enabled": null,
    "always_request_extended_authorization": null,
    "is_click_to_pay_enabled": false,
    "authentication_product_ids": null,
    "card_testing_guard_config": {
        "card_ip_blocking_status": "disabled",
        "card_ip_blocking_threshold": 3,
        "guest_user_card_blocking_status": "disabled",
        "guest_user_card_blocking_threshold": 10,
        "customer_id_blocking_status": "disabled",
        "customer_id_blocking_threshold": 5,
        "card_testing_guard_expiry": 3600
    },
    "is_clear_pan_retries_enabled": false
}

Checklist

  • I formatted the code cargo +nightly fmt --all
  • I addressed lints thrown by cargo clippy
  • I reviewed the submitted code
  • I added unit tests for my changes where possible

@swetasharma03 swetasharma03 added A-core Area: Core flows C-feature Category: Feature request or enhancement labels Mar 19, 2025
@swetasharma03 swetasharma03 self-assigned this Mar 19, 2025
@swetasharma03 swetasharma03 requested review from a team as code owners March 19, 2025 19:18
@semanticdiff-com
Copy link

semanticdiff-com bot commented Mar 19, 2025

Review changes with  SemanticDiff

Changed Files
File Status
  crates/router/src/utils.rs  59% smaller
  crates/router/src/workflows/outgoing_webhook_retry.rs  58% smaller
  crates/router/src/core/webhooks/outgoing.rs  51% smaller
  crates/router/src/core/webhooks/incoming.rs  40% smaller
  crates/router/src/core/webhooks.rs  36% smaller
  crates/router/src/core/webhooks/utils.rs  16% smaller
  crates/api_models/src/admin.rs  0% smaller
  crates/api_models/src/webhook_events.rs  0% smaller
  crates/common_enums/src/enums.rs  0% smaller
  crates/common_utils/src/id_type.rs  0% smaller
  crates/common_utils/src/id_type/webhook_endpoint.rs  0% smaller
  crates/common_utils/src/lib.rs  0% smaller
  crates/diesel_models/src/business_profile.rs  0% smaller
  crates/diesel_models/src/events.rs  0% smaller
  crates/diesel_models/src/schema.rs  0% smaller
  crates/diesel_models/src/schema_v2.rs  0% smaller
  crates/router/src/consts.rs  0% smaller
  crates/router/src/core/admin.rs  0% smaller
  crates/router/src/core/webhooks/types.rs  0% smaller
  crates/router/src/core/webhooks/webhook_events.rs  0% smaller
  crates/router/src/db/events.rs  0% smaller
  crates/router/src/routes/profiles.rs  0% smaller
  crates/router/src/types/domain/event.rs  0% smaller
  crates/router/src/types/transformers.rs  0% smaller
  migrations/2025-03-17-080827_alter_events_table/down.sql Unsupported file format
  migrations/2025-03-17-080827_alter_events_table/up.sql Unsupported file format

@hyperswitch-bot hyperswitch-bot bot added the M-database-changes Metadata: This PR involves database schema changes label Mar 19, 2025

/// Webhook related details
pub webhook_details: Option<WebhookDetails>,
pub webhook_details: Option<Vec<Option<WebhookDetails>>>,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are two options here, which seem redundant. Is there any specific reason to do so?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After the migration, the schema.rs file generates the webhook_details field as Nullable<Array<Nullable<Json>>>.
The reason for having both options Option<Vec<Option<WebhookDetails>>> is to align with the schema generated after the migration.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is API related change in api-model, we shouldn't modify this for database related things, and changing a type of existing argument violate backward compatibility.

///The password for Webhook login
#[schema(value_type = Option<String>, max_length = 255, example = "ekart@123")]
pub webhook_password: Option<Secret<String>>,
pub webhook_endpoint_id: Option<id_type::WebhookEndpointId>,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this change backwards compatible?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the change is backwards compatible. Since webhook_endpoint_id is nullable, for the previous webhook_details, its value will be None.

id_type::ProfileId::generate()
}

/// Generate a profile id with default length, with prefix as `pro`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please change the doc comment

#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum OutgoingWebhookEndpointStatus {
Active,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please provide description to these enums. What is the difference between inactive and deprecated? if once made as deprecated, it cannot be made active?

@swetasharma03 swetasharma03 force-pushed the multiple-outgoing-webhooks branch 4 times, most recently from e53313d to 1c4c899 Compare March 26, 2025 08:37
@swetasharma03 swetasharma03 force-pushed the multiple-outgoing-webhooks branch 11 times, most recently from f7a5ce1 to 66940ff Compare April 7, 2025 12:10

/// Webhook related details
pub webhook_details: Option<WebhookDetails>,
pub webhook_details: Option<Vec<Option<WebhookDetails>>>,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is API related change in api-model, we shouldn't modify this for database related things, and changing a type of existing argument violate backward compatibility.

pub merchant_name: Option<Encryption>,
pub merchant_details: Option<Encryption>,
pub webhook_details: Option<crate::business_profile::WebhookDetails>,
pub webhook_details: Option<Vec<Option<crate::business_profile::WebhookDetails>>>,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
pub webhook_details: Option<Vec<Option<crate::business_profile::WebhookDetails>>>,
pub webhook_details: Option<Vec<Option<business_profile::WebhookDetails>>>,

pub merchant_name: Option<Encryption>,
pub merchant_details: Option<Encryption>,
pub webhook_details: Option<crate::business_profile::WebhookDetails>,
pub webhook_details: Option<Vec<Option<crate::business_profile::WebhookDetails>>>,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
pub webhook_details: Option<Vec<Option<crate::business_profile::WebhookDetails>>>,
pub webhook_details: Option<Vec<Option<business_profile::WebhookDetails>>>,

pub merchant_details: Option<Encryption>,
pub return_url: Option<String>,
pub webhook_details: Option<crate::business_profile::WebhookDetails>,
pub webhook_details: Option<Vec<Option<crate::business_profile::WebhookDetails>>>,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
pub webhook_details: Option<Vec<Option<crate::business_profile::WebhookDetails>>>,
pub webhook_details: Option<Vec<Option<business_profile::WebhookDetails>>>,

pub merchant_details: Option<Encryption>,
pub return_url: Option<String>,
pub webhook_details: Option<crate::business_profile::WebhookDetails>,
pub webhook_details: Option<Vec<Option<crate::business_profile::WebhookDetails>>>,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
pub webhook_details: Option<Vec<Option<crate::business_profile::WebhookDetails>>>,
pub webhook_details: Option<Vec<Option<business_profile::WebhookDetails>>>,

@swetasharma03 swetasharma03 force-pushed the multiple-outgoing-webhooks branch from a162510 to 910966f Compare April 23, 2025 22:28
@swetasharma03 swetasharma03 force-pushed the multiple-outgoing-webhooks branch from 910966f to 7781f38 Compare May 14, 2025 09:27
@swetasharma03 swetasharma03 force-pushed the multiple-outgoing-webhooks branch from 7781f38 to 26f749b Compare May 14, 2025 11:39
@swetasharma03 swetasharma03 force-pushed the multiple-outgoing-webhooks branch from ed5010c to a9cdf24 Compare May 14, 2025 11:41
Copy link
Member

@SanchithHegde SanchithHegde left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, please include more test cases in the PR description:

  • Ensuring that webhooks are correctly being sent to all webhook endpoints in case multiple endpoints are configured in the business profile.
  • Ensuring that automatic and manual retries works for all webhook endpoints.
  • Ensuring that no change in behavior is observed for old business profiles with old data: webhook delivery of initial attempts, automatic retries and manual retries must happen as they used to previously.
  • Ensuring that analytics events are being correctly populated in the analytics pipeline, for all webhook endpoints. (Please set up Kafka and ClickHouse locally using Docker Compose, test it out and include screenshots.)

);
crate::impl_id_type_methods!(WebhookEndpointId, "webhook_endpoint_id");
crate::impl_generate_id_id_type!(WebhookEndpointId, "web");
crate::impl_default_id_type!(WebhookEndpointId, "web");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we absolutely need to derive Default, or can we remove this line?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need this Default implementation to handle cases where webhook_endpoint_id is missing in older data. It allows us to convert the available data into a multiple_webhook_list, particularly in functions like get_webhook_details_for_event_type.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't we make use of the generate_webhook_endpoint_id_of_default_length() function instead of using the Default trait?

The reason I don't prefer implementing the Default trait is because the implementation generates a random ID by default, and is not a static value. While Default trait implementations typically involve static values.

#[allow(clippy::too_many_arguments)]
pub async fn trigger_payments_webhook<F, Op, D>(
merchant_context: domain::MerchantContext,
_merchant_context: domain::MerchantContext,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this variable / parameter no longer being read?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We were calling fn create_event_and_trigger_outgoing_webhook previously in trigger_payments_webhook fn, which required merchant_context to be passed as a parameter. But now we are calling add_bulk_outgoing_webhook_task_to_process_tracker from trigger_payments_webhook, which is why we don't need this parameter anymore.

@swetasharma03 swetasharma03 force-pushed the multiple-outgoing-webhooks branch 3 times, most recently from 527c5dc to 243affb Compare May 29, 2025 11:02
@swetasharma03 swetasharma03 force-pushed the multiple-outgoing-webhooks branch from 243affb to b366272 Compare June 4, 2025 04:24
Copy link
Member

@SanchithHegde SanchithHegde left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please include these test cases in the description, in addition to the existing ones:

  • Ensuring that no change in behavior is observed for old business profiles with old data: webhook delivery of initial attempts, automatic retries and manual retries must happen (by the new application) as they used to previously. (These could be business profiles created from code on our main branch for example.)
  • Ensuring that analytics events are being correctly populated in the analytics pipeline, for all webhook endpoints. (Please set up Kafka and ClickHouse locally using Docker Compose, test it out and include screenshots.)
  • Ensuring that old application (or code from main branch) can handle webhook deliveries (initial attempt, automatic and manual retries) for profiles created with new application.
    • This is to ensure that staggering deployments or rollbacks to the previous version would not break anything.
    • Of course, we wouldn't be able to deliver webhooks to all endpoints in this case, we would only be able to deliver webhooks to the URL in the webhook_url field.

And just so you're aware, we also have a v2 implementation of outgoing webhooks. We can take up adding support for multiple webhook endpoints for v2 in a separate PR. (Looping in @Aishwariyaa-Anand.)

);
crate::impl_id_type_methods!(WebhookEndpointId, "webhook_endpoint_id");
crate::impl_generate_id_id_type!(WebhookEndpointId, "web");
crate::impl_default_id_type!(WebhookEndpointId, "web");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't we make use of the generate_webhook_endpoint_id_of_default_length() function instead of using the Default trait?

The reason I don't prefer implementing the Default trait is because the implementation generates a random ID by default, and is not a static value. While Default trait implementations typically involve static values.


#[derive(Clone, Debug, serde::Deserialize, serde::Serialize, diesel::AsExpression)]
#[diesel(sql_type = diesel::sql_types::Json)]
pub struct MultipleWebhookDetail {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you please add the two fields then? Won't hurt to have this information I feel.

Comment on lines +698 to 703
pub struct MultipleWebhookDetail {
pub webhook_endpoint_id: Option<id_type::WebhookEndpointId>,
pub webhook_url: Option<Secret<String>>,
pub events: Vec<common_enums::EventType>,
pub status: Option<common_enums::OutgoingWebhookEndpointStatus>,
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we don't need them to provide specific fields in the request, can we consider using two different types, one for request and one for response (say MultipleWebhookDetailRequest and MultipleWebhookDetailResponse or something similar)?

The response type could have fields similar to the diesel model type (because all of the data in the response would be populated from the diesel model anyway), while the request need not have fields like webhook_endpoint_id at all, while status may be optionally provided.

Comment on lines +160 to +162
// Hash and encode the input using SHA-256 and URL-safe base64 (without padding)
let hash = Sha256::digest(input.as_bytes());
let encoded = URL_SAFE_NO_PAD.encode(hash);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Please use Sha256 from the common_utils crate instead.
  2. And for base64 encoding, add a const in common_utils crate called BASE64_ENGINE_URL_SAFE_NO_PAD:
    /// General purpose base64 engine
    pub const BASE64_ENGINE: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD;
    /// URL Safe base64 engine
    pub const BASE64_ENGINE_URL_SAFE: base64::engine::GeneralPurpose =
    base64::engine::general_purpose::URL_SAFE;
  3. Also, please add a comment explaining why we're doing this hashing + encoding (and why we're not using the concatenated value directly).

Comment on lines +164 to +169
// Truncate to MAX_PREFIX_LEN (56) if needed
let common_prefix = if encoded.len() > MAX_PREFIX_LEN {
&encoded[..MAX_PREFIX_LEN]
} else {
&encoded
};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Truncating most likely won't be needed, considering SHA256 output would be 256 bits (or 32 bytes), and base64 encoding adds ~33% overhead, so encoded output should be of size ~43 characters (32 * 1.33), well within our limit of 56 characters.

Comment on lines +151 to +152
for webhook_detail in webhook_details.iter() {
let webhook_detail_clone = webhook_detail.clone();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: You should be able to do something like:

Suggested change
for webhook_detail in webhook_details.iter() {
let webhook_detail_clone = webhook_detail.clone();
for webhook_detail in webhook_details {

And you can avoid the webhook_detail.clone().

webhook_endpoint_id: initial_event.webhook_endpoint_id,
is_overall_delivery_successful: Some(false),
};
let webhook_id = new_event.webhook_endpoint_id.clone();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Naming.

Suggested change
let webhook_id = new_event.webhook_endpoint_id.clone();
let webhook_endpoint_id = new_event.webhook_endpoint_id.clone();

Comment on lines +7 to +9
use common_enums::EventClass;
use common_utils::{errors::ReportSwitchExt, events::ApiEventsType, ext_traits::AsyncExt};
use diesel_models::ConnectorMandateReferenceId;
use diesel_models::{enums::EventObjectType, ConnectorMandateReferenceId};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's avoid these direct imports please, we can qualify them with enums:: instead.

Comment on lines +116 to +123
let (content, _) = Box::pin(get_outgoing_webhook_content_and_event_type(
state.clone(),
state.get_req_state(),
merchant_account.clone(),
merchant_key_store.clone(),
&tracking_data,
))
.await?;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_outgoing_webhook_content_and_event_type() isn't meant to be used as the primary way of obtaining the outgoing webhook content: it was added for backward compatibility reasons.

Another reason I would prefer to completely avoid it is that it has the caveat that the resource could have transitioned to a completely different status (as compared to what we're notifying about) by the time the workflow would be executed.

We previously had checks for it:

match event_type {
// Resource status is same as the event type of the current event
Some(event_type) if event_type == tracking_data.event_type => {
let outgoing_webhook = OutgoingWebhook {
merchant_id: tracking_data.merchant_id.clone(),
event_id: event.event_id.clone(),
event_type,
content: content.clone(),
timestamp: event.created_at,
};
let request_content = webhooks_core::get_outgoing_webhook_request(
&merchant_context,
outgoing_webhook,
&business_profile,
)
.map_err(|error| {
logger::error!(
?error,
"Failed to obtain outgoing webhook request content"
);
errors::ProcessTrackerError::EApiErrorResponse
})?;
Box::pin(webhooks_core::trigger_webhook_and_raise_event(
state.clone(),
business_profile,
&key_store,
event,
request_content,
delivery_attempt,
Some(content),
Some(process),
))
.await;
}
// Resource status has changed since the event was created, finish task
_ => {
logger::warn!(
%event.event_id,
"The current status of the resource `{:?}` (event type: {:?}) and the status of \
the resource when the event was created (event type: {:?}) differ, finishing task",
tracking_data.primary_object_id,
event_type,
tracking_data.event_type
);
db.as_scheduler()
.finish_process_with_business_status(
process.clone(),
business_status::RESOURCE_STATUS_MISMATCH,
)
.await?;
}
}

&mandate_id,
storage::MandateUpdate::StatusUpdate { mandate_status },
mandate,
mandate.clone(),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the clone?

use error_stack::{report, Report, ResultExt};
use hyperswitch_domain_models::type_encryption::{crypto_operation, CryptoOperation};
use hyperswitch_interfaces::consts;
use masking::{ExposeInterface, Mask, PeekInterface, Secret};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
use masking::{ExposeInterface, Mask, PeekInterface, Secret};
use masking;

Avoid using direct imports


const OUTGOING_WEBHOOK_TIMEOUT_SECS: u64 = 5;
pub const OUTGOING_WEBHOOK_BULK_TASK: &str = "OUTGOING_WEBHOOK_BULK";
pub const OUTGOING_WEBHOOK_RETRY_TASK: &str = "OUTGOING_WEBHOOK_RETRY";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we move these constants to crates/router/src/core/webhooks/types.rs ?
They are used across v1 and v2 files.

pub const OUTGOING_WEBHOOK_BULK_TASK: &str = "OUTGOING_WEBHOOK_BULK";
pub const OUTGOING_WEBHOOK_RETRY_TASK: &str = "OUTGOING_WEBHOOK_RETRY";

pub(crate) fn get_webhook_details_for_event_type(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we reorder the functions to reflect the logical order of execution? That would help improve readability and make the flow easier to follow. Something like:

add_bulk_outgoing_webhook_task_to_process_tracker
create_event_and_trigger_outgoing_webhook
trigger_webhook_and_raise_event
trigger_webhook_to_merchant
raise_webhooks_analytics_event

@@ -1,4 +1,4 @@
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. In trigger_webhook_to_merchant, where we match on delivery_attempt, we could introduce a trait to handle successful and failed deliveries for InitialAttempt, AutomaticRetry, and ManualRetry. This will help decouple the logic and improve code readability.
  2. Functions like increment_webhook_outgoing_received_count and increment_webhook_outgoing_not_received_count can be reused from crates/router/src/core/webhooks/utils.rs instead of redefining.
  3. In raise_webhooks_analytics_event, we could pass the trigger_webhook_result directly to help avoid an extra DB fetch for the updated event:
trigger_webhook_result: CustomResult<
       (domain::Event, Option<Report<errors::WebhooksFlowError>>),
       errors::WebhooksFlowError,
   >,

You can refer to this PR #6613
These changes can be taken up in a separate PR

.to_not_found_response(errors::ApiErrorResponse::MerchantAccountNotFound)?;

#[cfg(feature = "v1")]
if let Some(ref webhook_details) = &req.webhook_details {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about we introduce a separate set of webhook endpoints for create, update and delete.
This would make the code logic simple and also would make it easier for the merchant in case of webhook updation.

@swetasharma03
Copy link
Contributor Author

#7578 (comment)
As adding new endpoints will increase complexity of the PR, we will be breaking out this PR into 5 new fresh PRs.
CC: @SanchithHegde @Aishwariyaa-Anand

@SanchithHegde SanchithHegde deleted the multiple-outgoing-webhooks branch October 13, 2025 06:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-core Area: Core flows C-feature Category: Feature request or enhancement M-database-changes Metadata: This PR involves database schema changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants