diff --git a/README.md b/README.md
index 5e12f9a..34a9c52 100644
--- a/README.md
+++ b/README.md
@@ -144,6 +144,22 @@ client = AsyncClinet()
+### Using Webhooks (Optional)
+
+For asynchronous response delivery, you can provide a `webhook_url` when initializing the client. The API will send the response to your webhook endpoint instead of using the polling mechanism.
+
+```python
+from magicalapi.client import AsyncClient
+
+# Your webhook must be whitelisted in MagicalAPI panel
+webhook_url = "https://your-domain.com/webhook"
+client = AsyncClient(webhook_url=webhook_url)
+```
+
+**Important:** Webhook domains must be registered in the [MagicalAPI panel](https://panel.magicalapi.com/). For setup guide, see the [webhook documentation](https://docs.magicalapi.com/docs/webhook) and [example](https://github.com/magicalapi/magicalapi-python/blob/master/examples/webhook_example.py).
+
+
+
Here is an example of how to parse a resume using [Resume Parser](https://magicalapi.com/resume/) service:
```python
diff --git a/examples/webhook_example.py b/examples/webhook_example.py
new file mode 100644
index 0000000..6c590b7
--- /dev/null
+++ b/examples/webhook_example.py
@@ -0,0 +1,45 @@
+"""
+Example: Using webhook_url for asynchronous response delivery
+
+IMPORTANT: Before using webhooks, you must register your webhook domain
+in the whitelist via the MagicalAPI panel: https://panel.magicalapi.com/
+
+For complete setup guide: https://docs.magicalapi.com/docs/webhook
+"""
+
+import asyncio
+
+from magicalapi.client import AsyncClient
+from magicalapi.types.base import ErrorResponse
+from magicalapi.types.schemas import WebhookCreatedResponse
+
+
+async def main():
+ """Example of using webhook URL with resume parser service."""
+
+ # Your webhook endpoint (must be whitelisted in MagicalAPI panel)
+ webhook_url = "https://your-domain.com/webhook/magical-api"
+
+ async with AsyncClient(webhook_url=webhook_url) as client:
+ # Make a request - the full response will be sent to your webhook_url
+ response = await client.resume_parser.get_resume_parser(
+ url="https://pub-4aa6fc29899047be8d4a342594b2c033.r2.dev/00016-poduct-manager-resume-example.pdf"
+ )
+
+ # With webhook_url, you get an immediate acknowledgment
+ if isinstance(response, WebhookCreatedResponse):
+ print(f"✅ Request accepted! Status: {response.data.status}")
+ print(f"The full response will be sent to: {webhook_url}")
+ print(f"Credits: {response.usage.credits}")
+
+ # Handle errors (e.g., domain not whitelisted)
+ elif isinstance(response, ErrorResponse):
+ if response.status_code == 403:
+ print(f"❌ Error: {response.message}")
+ print("Register your domain at: https://panel.magicalapi.com/")
+ else:
+ print(f"Error {response.status_code}: {response.message}")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/magicalapi/client.py b/magicalapi/client.py
index 51e9bfc..f91fac1 100644
--- a/magicalapi/client.py
+++ b/magicalapi/client.py
@@ -20,15 +20,26 @@ class AsyncClient(AbstractAsyncContextManager): # type: ignore
The MagicalAPI client module to work and connect to the api.
"""
- def __init__(self, api_key: str | None = None) -> None:
- """initializing MagicalAPI client.
-
-
- api_key(``str``):
- your Magical API account's `api_key` that you can get it from https://panel.magicalapi.com/
-
- if passed empty, `api_key` will read from the .env file.
-
+ def __init__(
+ self, api_key: str | None = None, webhook_url: str | None = None
+ ) -> None:
+ """Initialize the MagicalAPI async client.
+
+ Args:
+ api_key (str | None): Your Magical API account's API key from https://panel.magicalapi.com/
+ If None, will be read from .env file or MAG_API_KEY environment variable.
+
+ webhook_url (str | None): Optional webhook URL to receive asynchronous responses.
+ When provided, API responses will be sent to this URL instead of using
+ the polling mechanism. If None, will be read from MAG_WEBHOOK_URL environment
+ variable. The webhook listener must be implemented by the user.
+
+ **IMPORTANT**: Your webhook domain must be registered in the whitelist via
+ the MagicalAPI panel. Unregistered domains will receive a 403 error.
+ See https://docs.magicalapi.com/docs/webhook for complete setup guide.
+
+ Raises:
+ TypeError: If api_key is not a string or None.
"""
# get api_key from .env or from arguments
@@ -42,6 +53,10 @@ def __init__(self, api_key: str | None = None) -> None:
f'api_key field type must be string, not a "{api_key.__class__.__name__}"'
)
+ # get webhook_url from settings if not provided
+ if webhook_url is None:
+ webhook_url = settings.webhook_url
+
self._api_key = api_key
_request_headers = {
"api-key": self._api_key,
@@ -55,11 +70,21 @@ def __init__(self, api_key: str | None = None) -> None:
logger.debug("httpx client created")
# create service
- self.profile_data = ProfileDataService(httpx_client=self._httpx_client)
- self.company_data = CompanyDataService(httpx_client=self._httpx_client)
- self.resume_parser = ResumeParserService(httpx_client=self._httpx_client)
- self.resume_score = ResumeScoreService(httpx_client=self._httpx_client)
- self.resume_review = ResumeReviewService(httpx_client=self._httpx_client)
+ self.profile_data = ProfileDataService(
+ httpx_client=self._httpx_client, webhook_url=webhook_url
+ )
+ self.company_data = CompanyDataService(
+ httpx_client=self._httpx_client, webhook_url=webhook_url
+ )
+ self.resume_parser = ResumeParserService(
+ httpx_client=self._httpx_client, webhook_url=webhook_url
+ )
+ self.resume_score = ResumeScoreService(
+ httpx_client=self._httpx_client, webhook_url=webhook_url
+ )
+ self.resume_review = ResumeReviewService(
+ httpx_client=self._httpx_client, webhook_url=webhook_url
+ )
logger.debug(f"async client created : {self}")
diff --git a/magicalapi/services/base_service.py b/magicalapi/services/base_service.py
index 3f687fc..75a2324 100644
--- a/magicalapi/services/base_service.py
+++ b/magicalapi/services/base_service.py
@@ -1,7 +1,8 @@
"""
-base service implementations,
-sending requests and validating the responses.
+Base service implementations for sending requests and validating responses.
+This module provides the base service class that handles HTTP communication
+with the MagicalAPI server, including webhook support and retry logic.
"""
import asyncio
@@ -15,26 +16,67 @@
from magicalapi.errors import APIServerError, APIServerTimedout
from magicalapi.settings import settings
from magicalapi.types.base import ErrorResponse
-from magicalapi.types.schemas import HttpResponse, PendingResponse
+from magicalapi.types.schemas import (
+ HttpResponse,
+ PendingResponse,
+ WebhookCreatedResponse,
+)
from magicalapi.utils.logger import get_logger
logger = get_logger("base_service")
class BaseService(BaseServiceAbc):
- def __init__(self, httpx_client: httpx.AsyncClient) -> None:
+ def __init__(
+ self, httpx_client: httpx.AsyncClient, webhook_url: str | None = None
+ ) -> None:
+ """Initialize the base service.
+
+ Args:
+ httpx_client (httpx.AsyncClient): The HTTP client for making requests.
+ webhook_url (str | None): Optional webhook URL for asynchronous responses.
+ When provided, responses will be sent to this URL instead of being
+ returned synchronously via the polling mechanism.
+
+ Note: Webhook domain must be whitelisted in MagicalAPI panel.
+ See https://docs.magicalapi.com/docs/webhook
+ """
self._httpx_client = httpx_client
+ self._webhook_url = webhook_url
async def _send_post_request(
self, path: str, data: dict[str, Any], headers: dict[str, str] = {}
) -> HttpResponse:
- """
- send a post request to the API server with given `path` and `data`
+ """Send a POST request to the API server.
+
+ Args:
+ path (str): The API endpoint path.
+ data (dict[str, Any]): The request payload.
+ headers (dict[str, str]): Optional request headers.
+
+ Returns:
+ HttpResponse: The response from the API server.
+
+ Raises:
+ APIServerTimedout: If the request exceeds the timeout limit.
+
+ Note:
+ - If webhook_url is configured, it will be added to the request payload
+ and the method returns immediately without polling for completion.
+ - If webhook_url is not configured, the method will poll for completion
+ using the 201 status code retry mechanism with request_id.
+ - Webhook domains must be whitelisted via MagicalAPI panel to avoid 403 errors.
+ See https://docs.magicalapi.com/docs/webhook for setup instructions.
"""
try:
logger.debug(
f"sending POST request : {self._httpx_client.base_url.join(path)}"
)
+ # Add webhook_url to request if configured (for async webhook delivery)
+ if self._webhook_url is not None:
+ logger.debug(f"Adding webhook_url to request: {self._webhook_url}")
+ data["webhook_url"] = self._webhook_url
+
httpx_response = await self._httpx_client.post(
headers=headers,
url=path,
@@ -43,8 +85,17 @@ async def _send_post_request(
logger.info(
f"{self._httpx_client.base_url}{path} got status code {httpx_response.status_code}"
)
- # check 201 response
+
+ # Skip polling retry loop when using webhook (response will be sent to webhook_url)
+ if self._webhook_url is not None:
+ logger.debug("Webhook mode: returning immediately without polling")
+ return HttpResponse(
+ text=httpx_response.text,
+ status_code=httpx_response.status_code,
+ )
+
_credits = 0
+ # retry to get the full response
while httpx_response.status_code == 201:
# send request with request_id
logger.debug("hadnling response with status code 201.")
@@ -54,7 +105,7 @@ async def _send_post_request(
data["request_id"] = pend_response.data.request_id
logger.info(
- f"send request again with requst_id : \"{data['request_id']}\""
+ f'send request again with requst_id : "{data["request_id"]}"'
)
# send request again
httpx_response = await self._httpx_client.post(
@@ -107,27 +158,43 @@ async def _send_get_request(
def validate_response(
self, response: HttpResponse, validate_model: type[BaseModel]
):
+ """Validate and parse the API response.
+
+ Args:
+ response (HttpResponse): The HTTP response to validate.
+ validate_model (type[BaseModel]): The Pydantic model to validate against.
+
+ Returns:
+ BaseModel | ErrorResponse | WebhookCreatedResponse: The validated response model.
+ - Returns validate_model instance for successful 200 responses
+ - Returns WebhookCreatedResponse for 201 responses when using webhooks
+ - Returns ErrorResponse for error status codes
+
+ Raises:
+ APIServerError: If response parsing or validation fails.
"""
- this method validate the response from API and returns the correct model basesd on response type.
- """
- # check response successed
- logger.debug("validating response.")
- logger.debug(f"response : {response}")
+ logger.debug("Validating response")
+ logger.debug(f"Response: {response}")
+
+ # Validate webhook acknowledgment response (201 with webhook_url)
+ if response.status_code == 201 and self._webhook_url is not None:
+ logger.debug("Validating webhook created response")
+ return WebhookCreatedResponse.model_validate_json(response.text)
+
+ # Validate successful response (200)
if response.status_code == 200:
try:
- # valdiate model
+ # Validate and parse response with the expected model
return validate_model.model_validate_json(response.text)
except ValidationError:
logger.exception(
- f"parsing response JSON data for model {validate_model.__name__} failed!"
+ f"Failed to parse response JSON for model {validate_model.__name__}"
)
- # raise exception
- raise APIServerError("parsing response JSON data failed!")
+ raise APIServerError("Failed to parse response JSON data")
- # handle user error response
+ # Handle API error responses (4xx, 5xx)
try:
- # error response
- logger.debug("response returned an error response.")
+ logger.debug("Parsing error response")
_response_data = json.loads(response.text)
return ErrorResponse(status_code=response.status_code, **_response_data)
diff --git a/magicalapi/settings.py b/magicalapi/settings.py
index 157aca6..be183ed 100644
--- a/magicalapi/settings.py
+++ b/magicalapi/settings.py
@@ -19,6 +19,7 @@ class Settings(BaseSettings):
"""
api_key: str | None = None
+ webhook_url: str | None = None
base_url: HttpUrl = "https://gw.magicalapi.com"
retry_201_delay: int = 2 # seconds
request_timeout: int = 15 # seconds
diff --git a/magicalapi/types/schemas.py b/magicalapi/types/schemas.py
index 2702654..50beabb 100644
--- a/magicalapi/types/schemas.py
+++ b/magicalapi/types/schemas.py
@@ -1,4 +1,5 @@
from dataclasses import dataclass
+from typing import Literal
from pydantic import BaseModel
@@ -21,3 +22,13 @@ class RequestID(BaseModel):
class PendingResponse(BaseModel):
data: RequestID
usage: Usage
+
+
+class DataStatusResponse(BaseModel):
+ status: Literal["created", "pending", "completed"]
+ message: str
+
+
+class WebhookCreatedResponse(BaseModel):
+ data: DataStatusResponse
+ usage: Usage
diff --git a/pyproject.toml b/pyproject.toml
index 0848c17..1baee20 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "magicalapi"
-version = "1.4.0"
+version = "1.5.0"
description = "This is a Python client that provides easy access to the MagicalAPI.com services, fully type annotated, and asynchronous."
authors = [
{ name = "MagicalAPI", email = "info@magicalapi.com" }
diff --git a/smoke/test_smoke.py b/smoke/test_smoke.py
index ef29c43..a1b8ef0 100644
--- a/smoke/test_smoke.py
+++ b/smoke/test_smoke.py
@@ -12,11 +12,13 @@
import pytest_asyncio
from magicalapi.client import AsyncClient
+from magicalapi.types.base import ErrorResponse
from magicalapi.types.company_data import CompanyDataResponse
from magicalapi.types.profile_data import ProfileDataResponse
from magicalapi.types.resume_parser import ResumeParserResponse
from magicalapi.types.resume_review import ResumeReviewResponse
from magicalapi.types.resume_score import ResumeScoreResponse
+from magicalapi.types.schemas import WebhookCreatedResponse
@pytest_asyncio.fixture(scope="function")
diff --git a/tests/services/test_webhook_url.py b/tests/services/test_webhook_url.py
new file mode 100644
index 0000000..9b0eb6a
--- /dev/null
+++ b/tests/services/test_webhook_url.py
@@ -0,0 +1,155 @@
+"""
+Unit tests for webhook_url functionality.
+"""
+
+import json
+from collections.abc import AsyncGenerator
+
+import httpx
+import pytest
+import pytest_asyncio
+
+from magicalapi.client import AsyncClient
+from magicalapi.services.base_service import BaseService
+from magicalapi.types.schemas import HttpResponse, WebhookCreatedResponse
+
+
+@pytest_asyncio.fixture(scope="function")
+async def httpxclient() -> AsyncGenerator[httpx.AsyncClient]:
+ """Fixture to create an httpx client for testing."""
+ client = httpx.AsyncClient(
+ headers={"content-type": "application/json"},
+ )
+
+ yield client
+
+ await client.aclose()
+ del client
+
+
+@pytest.mark.asyncio
+async def test_base_service_with_webhook_url(httpxclient: httpx.AsyncClient):
+ """Test that webhook_url is properly added to request body."""
+ # Set a reasonable timeout for the client
+ httpxclient._timeout = httpx.Timeout(30.0)
+
+ webhook_url = "https://example.com/webhook"
+ base_service = BaseService(httpxclient, webhook_url=webhook_url)
+ test_data = {"foo": "bar"}
+
+ response = await base_service._send_post_request(
+ path="https://httpbin.org/post", data=test_data
+ )
+
+ # Verify response
+ assert isinstance(response, HttpResponse)
+ assert response.status_code == 200
+
+ # Verify webhook_url was added to the request
+ response_json = json.loads(response.text)
+ assert response_json["json"]["webhook_url"] == webhook_url
+ assert response_json["json"]["foo"] == "bar"
+
+
+@pytest.mark.asyncio
+async def test_base_service_without_webhook_url(httpxclient: httpx.AsyncClient):
+ """Test that request works without webhook_url."""
+ base_service = BaseService(httpxclient, webhook_url=None)
+ test_data = {"foo": "bar"}
+
+ response = await base_service._send_post_request(
+ path="https://httpbin.org/post", data=test_data
+ )
+
+ # Verify response
+ assert isinstance(response, HttpResponse)
+ assert response.status_code == 200
+
+ # Verify webhook_url was NOT added to the request
+ response_json = json.loads(response.text)
+ assert "webhook_url" not in response_json["json"]
+ assert response_json["json"]["foo"] == "bar"
+
+
+def test_validate_webhook_created_response(httpxclient: httpx.AsyncClient):
+ """Test validation of webhook created response (201 status)."""
+ webhook_url = "https://example.com/webhook"
+ base_service = BaseService(httpxclient, webhook_url=webhook_url)
+
+ # Mock a 201 webhook created response
+ fake_webhook_response = {
+ "data": {
+ "status": "created",
+ "message": "Request accepted, result will be sent to webhook",
+ },
+ "usage": {"credits": 5},
+ }
+
+ fake_response = HttpResponse(
+ text=json.dumps(fake_webhook_response), status_code=201
+ )
+
+ # Validate response
+ result = base_service.validate_response(
+ response=fake_response, validate_model=WebhookCreatedResponse
+ )
+
+ assert isinstance(result, WebhookCreatedResponse)
+ assert result.data.status == "created"
+ assert result.data.message == "Request accepted, result will be sent to webhook"
+ assert result.usage.credits == 5
+
+
+def test_validate_webhook_not_triggered_without_webhook_url(
+ httpxclient: httpx.AsyncClient,
+):
+ """Test that 201 response without webhook_url raises APIServerError."""
+ # No webhook_url provided
+ base_service = BaseService(httpxclient, webhook_url=None)
+
+ # Mock a 201 pending response (normal polling behavior)
+ # This represents a response that would trigger retry in _send_post_request
+ fake_pending_response = {
+ "data": {"request_id": "req_12345"},
+ "usage": {"credits": 0},
+ }
+
+ fake_response = HttpResponse(
+ text=json.dumps(fake_pending_response), status_code=201
+ )
+
+ # When validate_response receives a 201 without webhook_url,
+ # it should raise APIServerError since 201 is not a valid final status
+ # (it's meant to be handled by retry logic in _send_post_request)
+ from magicalapi.errors import APIServerError
+
+ with pytest.raises(APIServerError):
+ base_service.validate_response(
+ response=fake_response, validate_model=WebhookCreatedResponse
+ )
+
+
+def test_client_initialization_with_webhook_url():
+ """Test that AsyncClient accepts webhook_url parameter."""
+ # Create client with webhook_url
+ webhook_url = "https://example.com/webhook"
+ client = AsyncClient(api_key="test_key", webhook_url=webhook_url)
+
+ # Verify webhook_url is passed to services
+ assert client.profile_data._webhook_url == webhook_url
+ assert client.company_data._webhook_url == webhook_url
+ assert client.resume_parser._webhook_url == webhook_url
+ assert client.resume_score._webhook_url == webhook_url
+ assert client.resume_review._webhook_url == webhook_url
+
+
+def test_client_initialization_without_webhook_url():
+ """Test that AsyncClient works without webhook_url."""
+ client = AsyncClient(api_key="test_key")
+
+ # Verify webhook_url defaults to None
+ assert client.profile_data._webhook_url is None
+ assert client.company_data._webhook_url is None
+ assert client.resume_parser._webhook_url is None
+ assert client.resume_score._webhook_url is None
+ assert client.resume_review._webhook_url is None