Skip to content

feat: add Docuseal webhook endpoint for member agreement (#48) - #67

Closed
happysmile001 wants to merge 1 commit into
508-dev:mainfrom
happysmile001:feat/docuseal-webhook
Closed

feat: add Docuseal webhook endpoint for member agreement (#48)#67
happysmile001 wants to merge 1 commit into
508-dev:mainfrom
happysmile001:feat/docuseal-webhook

Conversation

@happysmile001

@happysmile001happysmile001 commented Feb 27, 2026

Copy link
Copy Markdown

Add /webhooks/docuseal endpoint that receives Docuseal form.completed events, looks up the signer by email in EspoCRM, and updates the cMemberAgreementSignedAt field on the contact.

NOTE: The CRM field name cMemberAgreementSignedAt is a placeholder following the existing c-prefix convention. The client should confirm the actual field name and adjust in docuseal_processor.py if needed.

Summary by CodeRabbit

  • New Features

    • Added Docuseal webhook integration that automatically processes completed form submissions.
    • System now automatically updates customer records in the CRM to mark member agreements as signed upon form completion.
  • Tests

    • Added comprehensive test coverage for webhook authorization, payload validation, and job handling.

Add /webhooks/docuseal endpoint that receives Docuseal form.completed
events, looks up the signer by email in EspoCRM, and updates the
cMemberAgreementSignedAt field on the contact.
NOTE: The CRM field name cMemberAgreementSignedAt is a placeholder
following the existing c-prefix convention. The client should confirm
the actual field name and adjust in docuseal_processor.py if needed.
@coderabbitai

coderabbitaiBot commented Feb 27, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Introduces a complete Docuseal webhook integration system. An API endpoint receives and validates webhook payloads, enqueues background jobs for form completion events, and processes them asynchronously to update CRM contact records with signed agreement timestamps.

Changes

Cohort / File(s)Summary
Webhook API Endpoint
apps/worker/src/five08/backend/api.py
New endpoint handler docuseal_webhook_handler that authenticates requests, validates JSON payloads against DocusealWebhookPayload, filters for "form.completed" events, and enqueues background jobs with idempotency keys. Returns 202 on success, 400/401/503 on errors.
Job System Integration
apps/worker/src/five08/worker/actors.py, apps/worker/src/five08/worker/jobs.py
Registers process_docuseal_agreement_job in the handler dispatch map and implements the job function that instantiates DocusealAgreementProcessor to process agreement data.
CRM Processor
apps/worker/src/five08/worker/crm/docuseal_processor.py
New DocusealAgreementProcessor class that queries EspoAPI to find contacts by email and updates their cMemberAgreementSignedAt field with completion timestamps. Includes error handling for CRM search/update failures.
Data Models
apps/worker/src/five08/worker/models.py
Adds DocusealSubmitter and DocusealWebhookPayload Pydantic models to validate incoming webhook data with fields for event type, timestamp, submitter identity, and completion details.
Tests
tests/unit/test_backend_api.py, tests/unit/test_worker_models.py
Comprehensive test coverage for webhook authorization, payload validation, job enqueueing with idempotency verification, event filtering, CRM failures, and model parsing.

Sequence Diagram

sequenceDiagram
actor Client as Docuseal<br/>(Client)
participant API as API<br/>Endpoint
participant Queue as Job<br/>Queue
participant Worker as Background<br/>Worker
participant CRM as EspoAPI<br/>(CRM)
Client->>API: POST /webhooks/docuseal<br/>(form.completed event)
API->>API: Validate auth & payload
API->>API: Filter event type
API->>Queue: enqueue_job<br/>(process_docuseal_agreement_job)
Queue-->>API: Job enqueued
API-->>Client: 202 Accepted
Worker->>Queue: Pick up job
Worker->>Worker: DocusealAgreementProcessor<br/>instantiate
Worker->>CRM: GET /Contact<br/>(search by email)
CRM-->>Worker: Contact found<br/>(id, details)
Worker->>CRM: PUT /Contact/{id}<br/>(set cMemberAgreementSignedAt)
CRM-->>Worker: Update successful
Worker->>Worker: Log completion<br/>success: true
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~35 minutes

Poem

🐰 A webhook hops into view,
With forms now complete and true,
Through queues and jobs, our CRM knows,
Agreements signed—the workflow flows! ✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe pull request title accurately and concisely describes the main change: adding a new Docuseal webhook endpoint for processing member agreement signatures.
Docstring Coverage✅ PassedDocstring coverage is 88.89% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
tests/unit/test_backend_api.py (1)

603-681: Add regression tests for blank/whitespace email and timestamp inputs.

Current tests don’t cover empty-string edge cases for data.email and completed_at/timestamp, which are critical for robust ingest validation.

🧪 Suggested additional tests
+def test_docuseal_webhook_rejects_blank_email(+ client: TestClient,+ auth_headers: dict[str, str],+) -> None:+ payload = {+ **_DOCUSEAL_PAYLOAD,+ "data": {**_DOCUSEAL_PAYLOAD["data"], "email": " "},+ }+ response = client.post("/webhooks/docuseal", json=payload, headers=auth_headers)+ assert response.status_code == 400+ assert response.json()["error"] == "invalid_payload"+++def test_docuseal_webhook_rejects_blank_completed_timestamp(+ client: TestClient,+ auth_headers: dict[str, str],+) -> None:+ payload = {+ **_DOCUSEAL_PAYLOAD,+ "timestamp": " ",+ "data": {**_DOCUSEAL_PAYLOAD["data"], "completed_at": " "},+ }+ response = client.post("/webhooks/docuseal", json=payload, headers=auth_headers)+ assert response.status_code == 400+ assert response.json()["error"] == "invalid_payload"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@tests/unit/test_backend_api.py` around lines 603 - 681, Add regression tests
to tests/unit/test_backend_api.py to cover blank/whitespace edge cases for the
Docuseal webhook: create two new tests (e.g.,
test_docuseal_webhook_rejects_blank_email and
test_docuseal_webhook_rejects_blank_timestamp) that POST to "/webhooks/docuseal"
using payloads based on _DOCUSEAL_PAYLOAD but with data.email set to "" and " " (whitespace) and completed_at/timestamp set to "" and " " respectively, and
assert the handler returns a 400 with error "invalid_payload" (mirror the style
of test_docuseal_webhook_rejects_invalid_payload). Ensure the tests use the
TestClient and auth_headers fixtures and maintain naming/structure consistent
with existing tests so they exercise the same validation logic in the webhook
handler.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/worker/src/five08/backend/api.py`:
- Around line 718-729: Normalize and validate the Docuseal fields before calling
enqueue_job: compute email = (submitter.email or "").strip() and completed_at =
submitter.completed_at or payload.timestamp (normalize/validate if it's a
string), then if email == "" or completed_at is falsy raise/return a 400 HTTP
error (e.g., HTTPException) and do not call enqueue_job; otherwise pass the
normalized email and completed_at into enqueue_job (keeping
idempotency_key=f"docuseal-agreement:{submitter.id}" and using
process_docuseal_agreement_job) so only non-blank values are enqueued.
In `@apps/worker/src/five08/worker/crm/docuseal_processor.py`:
- Around line 43-77: Replace raw email logging to avoid PII: update the logger
calls in docuseal_processor.py (the logger.error in the CRM search exception,
the logger.warning when no contact is found, the logger.error in the
EspoAPIError except block, and the final logger.info) to omit or mask the
variable email and instead log contact_id or submission_id (or a deterministic
masked/hash of email). Specifically, change references that pass email into
logger.* (e.g., the "CRM search failed for email=%s", "No CRM contact found for
email=%s", "CRM update failed for contact_id=%s: %s" and "Marked member
agreement signed contact_id=%s email=%s") so they only interpolate
contact_id/submission_id or a masked_email variable (created by hashing or
redacting) and ensure error payloads returned in dicts do not expose the raw
email either.
---
Nitpick comments:
In `@tests/unit/test_backend_api.py`:
- Around line 603-681: Add regression tests to tests/unit/test_backend_api.py to
cover blank/whitespace edge cases for the Docuseal webhook: create two new tests
(e.g., test_docuseal_webhook_rejects_blank_email and
test_docuseal_webhook_rejects_blank_timestamp) that POST to "/webhooks/docuseal"
using payloads based on _DOCUSEAL_PAYLOAD but with data.email set to "" and " " (whitespace) and completed_at/timestamp set to "" and " " respectively, and
assert the handler returns a 400 with error "invalid_payload" (mirror the style
of test_docuseal_webhook_rejects_invalid_payload). Ensure the tests use the
TestClient and auth_headers fixtures and maintain naming/structure consistent
with existing tests so they exercise the same validation logic in the webhook
handler.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9989f1d and 2269949.

📒 Files selected for processing (7)
  • apps/worker/src/five08/backend/api.py
  • apps/worker/src/five08/worker/actors.py
  • apps/worker/src/five08/worker/crm/docuseal_processor.py
  • apps/worker/src/five08/worker/jobs.py
  • apps/worker/src/five08/worker/models.py
  • tests/unit/test_backend_api.py
  • tests/unit/test_worker_models.py

Comment on lines +718 to +729
submitter = payload.data
completed_at = submitter.completed_at or payload.timestamp
queue = request.app.state.queue
try:
job: EnqueuedJob = await asyncio.to_thread(
enqueue_job,
queue=queue,
fn=process_docuseal_agreement_job,
args=(submitter.email, completed_at, submitter.id),
settings=settings,
idempotency_key=f"docuseal-agreement:{submitter.id}",
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Normalize and reject blank Docuseal identity/timestamp fields before enqueue.

submitter.email and completed_at/timestamp are used directly; whitespace/empty strings can still be queued. Add strip + non-empty validation before enqueue_job.

✅ Suggested validation hardening diff
 submitter = payload.data
- completed_at = submitter.completed_at or payload.timestamp+ email = submitter.email.strip().lower()+ if not email:+ return JSONResponse(+ {"error": "invalid_payload", "detail": "data.email is required"},+ status_code=400,+ )++ completed_at = (submitter.completed_at or payload.timestamp).strip()+ if not completed_at:+ return JSONResponse(+ {"error": "invalid_payload", "detail": "completed_at/timestamp is required"},+ status_code=400,+ )
queue = request.app.state.queue
try:
job: EnqueuedJob = await asyncio.to_thread(
enqueue_job,
queue=queue,
fn=process_docuseal_agreement_job,
- args=(submitter.email, completed_at, submitter.id),+ args=(email, completed_at, submitter.id),
settings=settings,
idempotency_key=f"docuseal-agreement:{submitter.id}",
)
As per coding guidelines "Worker ingest endpoints must validate input, persist jobs, enqueue, and return 202 quickly without performing long processing".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/worker/src/five08/backend/api.py` around lines 718 - 729, Normalize and
validate the Docuseal fields before calling enqueue_job: compute email =
(submitter.email or "").strip() and completed_at = submitter.completed_at or
payload.timestamp (normalize/validate if it's a string), then if email == "" or
completed_at is falsy raise/return a 400 HTTP error (e.g., HTTPException) and do
not call enqueue_job; otherwise pass the normalized email and completed_at into
enqueue_job (keeping idempotency_key=f"docuseal-agreement:{submitter.id}" and
using process_docuseal_agreement_job) so only non-blank values are enqueued.

Comment on lines +43 to +77
logger.error("CRM search failed for email=%s: %s", email, exc)
return {
"success": False,
"email": email,
"error": f"CRM search failed: {exc}",
}

contacts = result.get("list", [])
if not contacts:
logger.warning("No CRM contact found for email=%s", email)
return {"success": False, "email": email, "error": "contact_not_found"}

contact = contacts[0]
contact_id = contact["id"]

try:
self.api.request(
"PUT",
f"Contact/{contact_id}",
{"cMemberAgreementSignedAt": completed_at},
)
except EspoAPIError as exc:
logger.error("CRM update failed for contact_id=%s: %s", contact_id, exc)
return {
"success": False,
"email": email,
"contact_id": contact_id,
"error": f"CRM update failed: {exc}",
}

logger.info(
"Marked member agreement signed contact_id=%s email=%s",
contact_id,
email,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Redact signer email from logs to avoid PII leakage.

The new log lines emit raw email addresses. Please log submission_id/contact_id (or a masked hash) instead of full email.

🔒 Suggested log-hardening diff
- logger.error("CRM search failed for email=%s: %s", email, exc)+ logger.error("CRM search failed submission_id=%s: %s", submission_id, exc)
...
- logger.warning("No CRM contact found for email=%s", email)+ logger.warning("No CRM contact found submission_id=%s", submission_id)
...
- "Marked member agreement signed contact_id=%s email=%s",+ "Marked member agreement signed contact_id=%s submission_id=%s",
contact_id,
- email,+ submission_id,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/worker/src/five08/worker/crm/docuseal_processor.py` around lines 43 -
77, Replace raw email logging to avoid PII: update the logger calls in
docuseal_processor.py (the logger.error in the CRM search exception, the
logger.warning when no contact is found, the logger.error in the EspoAPIError
except block, and the final logger.info) to omit or mask the variable email and
instead log contact_id or submission_id (or a deterministic masked/hash of
email). Specifically, change references that pass email into logger.* (e.g., the
"CRM search failed for email=%s", "No CRM contact found for email=%s", "CRM
update failed for contact_id=%s: %s" and "Marked member agreement signed
contact_id=%s email=%s") so they only interpolate contact_id/submission_id or a
masked_email variable (created by hashing or redacting) and ensure error
payloads returned in dicts do not expose the raw email either.

@michaelmwu

Copy link
Copy Markdown
Member

Great, thanks for this, I'll take it from here.

We should be filtering on the template being the member agreement.

The documentation of what a webhook looks like is:

{
"event_type": "form.completed",
"timestamp": "2026-02-28T00:18:34Z",
"data": {
"id": 123,
"submission_id": 123,
"email": "__@508.dev",
"phone": null,
"name": null,
"ua": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36",
"ip": "196.249.122.246",
"sent_at": "2026-02-27T19:55:47.564Z",
"opened_at": "2026-02-27T20:00:04.861Z",
"completed_at": "2026-02-27T20:01:07.888Z",
"declined_at": null,
"created_at": "2026-02-27T19:53:56.461Z",
"updated_at": "2026-02-27T20:01:07.892Z",
"external_id": null,
"metadata": {},
"status": "completed",
"application_key": null,
"decline_reason": null,
"role": "Tachera Sasi",
"preferences": {
"send_email": true
},
"values": [
{
"field": "Date Field 3",
"value": "2026-02-27"
},
{
"field": "Signature Field 3",
"value": "https://docuseal.508.dev/file/___.png"
}
],
"documents": [
{
"name": "508.dev Member Agreement",
"url": "https://docuseal.508.dev/file/_____.dif"
}
],
"audit_log_url": "https://docuseal.508.dev/file/____.pdf",
"submission_url": "https://docuseal.508.dev/e/iKjYF8WWD5kWxC",
"template": {
"id": 69,
"name": "508.dev Member Agreement",
"external_id": null,
"created_at": "2026-02-27T19:47:21.222Z",
"updated_at": "2026-02-27T20:49:42.420Z",
"folder_name": "Signed Agreements / External Contracts"
},
"submission": {
"id": 416,
"audit_log_url": "https://docuseal.508.dev/file/_____.pdf",
"combined_document_url": null,
"status": "completed",
"url": "https://docuseal.508.dev/e/iKjYF8WWD5kWxC",
"variables": {},
"created_at": "2026-02-27T19:53:56.452Z"
}
}
}

@michaelmwu

Copy link
Copy Markdown
Member

#70

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@happysmile001@michaelmwu@kanbei65
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat: add Docuseal webhook endpoint for member agreement (#48) by happysmile001 · Pull Request #67 · 508-dev/508-workflows · GitHub
Skip to content

feat: add Docuseal webhook endpoint for member agreement (#48) - #67

Closed
happysmile001 wants to merge 1 commit into
508-dev:mainfrom
happysmile001:feat/docuseal-webhook
Closed

feat: add Docuseal webhook endpoint for member agreement (#48)#67
happysmile001 wants to merge 1 commit into
508-dev:mainfrom
happysmile001:feat/docuseal-webhook

Conversation

@happysmile001

@happysmile001happysmile001 commented Feb 27, 2026

Copy link
Copy Markdown

Add /webhooks/docuseal endpoint that receives Docuseal form.completed events, looks up the signer by email in EspoCRM, and updates the cMemberAgreementSignedAt field on the contact.

NOTE: The CRM field name cMemberAgreementSignedAt is a placeholder following the existing c-prefix convention. The client should confirm the actual field name and adjust in docuseal_processor.py if needed.

Summary by CodeRabbit

  • New Features

    • Added Docuseal webhook integration that automatically processes completed form submissions.
    • System now automatically updates customer records in the CRM to mark member agreements as signed upon form completion.
  • Tests

    • Added comprehensive test coverage for webhook authorization, payload validation, and job handling.

Add /webhooks/docuseal endpoint that receives Docuseal form.completed
events, looks up the signer by email in EspoCRM, and updates the
cMemberAgreementSignedAt field on the contact.
NOTE: The CRM field name cMemberAgreementSignedAt is a placeholder
following the existing c-prefix convention. The client should confirm
the actual field name and adjust in docuseal_processor.py if needed.
@coderabbitai

coderabbitaiBot commented Feb 27, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Introduces a complete Docuseal webhook integration system. An API endpoint receives and validates webhook payloads, enqueues background jobs for form completion events, and processes them asynchronously to update CRM contact records with signed agreement timestamps.

Changes

Cohort / File(s)Summary
Webhook API Endpoint
apps/worker/src/five08/backend/api.py
New endpoint handler docuseal_webhook_handler that authenticates requests, validates JSON payloads against DocusealWebhookPayload, filters for "form.completed" events, and enqueues background jobs with idempotency keys. Returns 202 on success, 400/401/503 on errors.
Job System Integration
apps/worker/src/five08/worker/actors.py, apps/worker/src/five08/worker/jobs.py
Registers process_docuseal_agreement_job in the handler dispatch map and implements the job function that instantiates DocusealAgreementProcessor to process agreement data.
CRM Processor
apps/worker/src/five08/worker/crm/docuseal_processor.py
New DocusealAgreementProcessor class that queries EspoAPI to find contacts by email and updates their cMemberAgreementSignedAt field with completion timestamps. Includes error handling for CRM search/update failures.
Data Models
apps/worker/src/five08/worker/models.py
Adds DocusealSubmitter and DocusealWebhookPayload Pydantic models to validate incoming webhook data with fields for event type, timestamp, submitter identity, and completion details.
Tests
tests/unit/test_backend_api.py, tests/unit/test_worker_models.py
Comprehensive test coverage for webhook authorization, payload validation, job enqueueing with idempotency verification, event filtering, CRM failures, and model parsing.

Sequence Diagram

sequenceDiagram
actor Client as Docuseal<br/>(Client)
participant API as API<br/>Endpoint
participant Queue as Job<br/>Queue
participant Worker as Background<br/>Worker
participant CRM as EspoAPI<br/>(CRM)
Client->>API: POST /webhooks/docuseal<br/>(form.completed event)
API->>API: Validate auth & payload
API->>API: Filter event type
API->>Queue: enqueue_job<br/>(process_docuseal_agreement_job)
Queue-->>API: Job enqueued
API-->>Client: 202 Accepted
Worker->>Queue: Pick up job
Worker->>Worker: DocusealAgreementProcessor<br/>instantiate
Worker->>CRM: GET /Contact<br/>(search by email)
CRM-->>Worker: Contact found<br/>(id, details)
Worker->>CRM: PUT /Contact/{id}<br/>(set cMemberAgreementSignedAt)
CRM-->>Worker: Update successful
Worker->>Worker: Log completion<br/>success: true
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~35 minutes

Poem

🐰 A webhook hops into view,
With forms now complete and true,
Through queues and jobs, our CRM knows,
Agreements signed—the workflow flows! ✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe pull request title accurately and concisely describes the main change: adding a new Docuseal webhook endpoint for processing member agreement signatures.
Docstring Coverage✅ PassedDocstring coverage is 88.89% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
tests/unit/test_backend_api.py (1)

603-681: Add regression tests for blank/whitespace email and timestamp inputs.

Current tests don’t cover empty-string edge cases for data.email and completed_at/timestamp, which are critical for robust ingest validation.

🧪 Suggested additional tests
+def test_docuseal_webhook_rejects_blank_email(+ client: TestClient,+ auth_headers: dict[str, str],+) -> None:+ payload = {+ **_DOCUSEAL_PAYLOAD,+ "data": {**_DOCUSEAL_PAYLOAD["data"], "email": " "},+ }+ response = client.post("/webhooks/docuseal", json=payload, headers=auth_headers)+ assert response.status_code == 400+ assert response.json()["error"] == "invalid_payload"+++def test_docuseal_webhook_rejects_blank_completed_timestamp(+ client: TestClient,+ auth_headers: dict[str, str],+) -> None:+ payload = {+ **_DOCUSEAL_PAYLOAD,+ "timestamp": " ",+ "data": {**_DOCUSEAL_PAYLOAD["data"], "completed_at": " "},+ }+ response = client.post("/webhooks/docuseal", json=payload, headers=auth_headers)+ assert response.status_code == 400+ assert response.json()["error"] == "invalid_payload"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@tests/unit/test_backend_api.py` around lines 603 - 681, Add regression tests
to tests/unit/test_backend_api.py to cover blank/whitespace edge cases for the
Docuseal webhook: create two new tests (e.g.,
test_docuseal_webhook_rejects_blank_email and
test_docuseal_webhook_rejects_blank_timestamp) that POST to "/webhooks/docuseal"
using payloads based on _DOCUSEAL_PAYLOAD but with data.email set to "" and " " (whitespace) and completed_at/timestamp set to "" and " " respectively, and
assert the handler returns a 400 with error "invalid_payload" (mirror the style
of test_docuseal_webhook_rejects_invalid_payload). Ensure the tests use the
TestClient and auth_headers fixtures and maintain naming/structure consistent
with existing tests so they exercise the same validation logic in the webhook
handler.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/worker/src/five08/backend/api.py`:
- Around line 718-729: Normalize and validate the Docuseal fields before calling
enqueue_job: compute email = (submitter.email or "").strip() and completed_at =
submitter.completed_at or payload.timestamp (normalize/validate if it's a
string), then if email == "" or completed_at is falsy raise/return a 400 HTTP
error (e.g., HTTPException) and do not call enqueue_job; otherwise pass the
normalized email and completed_at into enqueue_job (keeping
idempotency_key=f"docuseal-agreement:{submitter.id}" and using
process_docuseal_agreement_job) so only non-blank values are enqueued.
In `@apps/worker/src/five08/worker/crm/docuseal_processor.py`:
- Around line 43-77: Replace raw email logging to avoid PII: update the logger
calls in docuseal_processor.py (the logger.error in the CRM search exception,
the logger.warning when no contact is found, the logger.error in the
EspoAPIError except block, and the final logger.info) to omit or mask the
variable email and instead log contact_id or submission_id (or a deterministic
masked/hash of email). Specifically, change references that pass email into
logger.* (e.g., the "CRM search failed for email=%s", "No CRM contact found for
email=%s", "CRM update failed for contact_id=%s: %s" and "Marked member
agreement signed contact_id=%s email=%s") so they only interpolate
contact_id/submission_id or a masked_email variable (created by hashing or
redacting) and ensure error payloads returned in dicts do not expose the raw
email either.
---
Nitpick comments:
In `@tests/unit/test_backend_api.py`:
- Around line 603-681: Add regression tests to tests/unit/test_backend_api.py to
cover blank/whitespace edge cases for the Docuseal webhook: create two new tests
(e.g., test_docuseal_webhook_rejects_blank_email and
test_docuseal_webhook_rejects_blank_timestamp) that POST to "/webhooks/docuseal"
using payloads based on _DOCUSEAL_PAYLOAD but with data.email set to "" and " " (whitespace) and completed_at/timestamp set to "" and " " respectively, and
assert the handler returns a 400 with error "invalid_payload" (mirror the style
of test_docuseal_webhook_rejects_invalid_payload). Ensure the tests use the
TestClient and auth_headers fixtures and maintain naming/structure consistent
with existing tests so they exercise the same validation logic in the webhook
handler.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9989f1d and 2269949.

📒 Files selected for processing (7)
  • apps/worker/src/five08/backend/api.py
  • apps/worker/src/five08/worker/actors.py
  • apps/worker/src/five08/worker/crm/docuseal_processor.py
  • apps/worker/src/five08/worker/jobs.py
  • apps/worker/src/five08/worker/models.py
  • tests/unit/test_backend_api.py
  • tests/unit/test_worker_models.py

Comment on lines +718 to +729
submitter = payload.data
completed_at = submitter.completed_at or payload.timestamp
queue = request.app.state.queue
try:
job: EnqueuedJob = await asyncio.to_thread(
enqueue_job,
queue=queue,
fn=process_docuseal_agreement_job,
args=(submitter.email, completed_at, submitter.id),
settings=settings,
idempotency_key=f"docuseal-agreement:{submitter.id}",
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Normalize and reject blank Docuseal identity/timestamp fields before enqueue.

submitter.email and completed_at/timestamp are used directly; whitespace/empty strings can still be queued. Add strip + non-empty validation before enqueue_job.

✅ Suggested validation hardening diff
 submitter = payload.data
- completed_at = submitter.completed_at or payload.timestamp+ email = submitter.email.strip().lower()+ if not email:+ return JSONResponse(+ {"error": "invalid_payload", "detail": "data.email is required"},+ status_code=400,+ )++ completed_at = (submitter.completed_at or payload.timestamp).strip()+ if not completed_at:+ return JSONResponse(+ {"error": "invalid_payload", "detail": "completed_at/timestamp is required"},+ status_code=400,+ )
queue = request.app.state.queue
try:
job: EnqueuedJob = await asyncio.to_thread(
enqueue_job,
queue=queue,
fn=process_docuseal_agreement_job,
- args=(submitter.email, completed_at, submitter.id),+ args=(email, completed_at, submitter.id),
settings=settings,
idempotency_key=f"docuseal-agreement:{submitter.id}",
)
As per coding guidelines "Worker ingest endpoints must validate input, persist jobs, enqueue, and return 202 quickly without performing long processing".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/worker/src/five08/backend/api.py` around lines 718 - 729, Normalize and
validate the Docuseal fields before calling enqueue_job: compute email =
(submitter.email or "").strip() and completed_at = submitter.completed_at or
payload.timestamp (normalize/validate if it's a string), then if email == "" or
completed_at is falsy raise/return a 400 HTTP error (e.g., HTTPException) and do
not call enqueue_job; otherwise pass the normalized email and completed_at into
enqueue_job (keeping idempotency_key=f"docuseal-agreement:{submitter.id}" and
using process_docuseal_agreement_job) so only non-blank values are enqueued.

Comment on lines +43 to +77
logger.error("CRM search failed for email=%s: %s", email, exc)
return {
"success": False,
"email": email,
"error": f"CRM search failed: {exc}",
}

contacts = result.get("list", [])
if not contacts:
logger.warning("No CRM contact found for email=%s", email)
return {"success": False, "email": email, "error": "contact_not_found"}

contact = contacts[0]
contact_id = contact["id"]

try:
self.api.request(
"PUT",
f"Contact/{contact_id}",
{"cMemberAgreementSignedAt": completed_at},
)
except EspoAPIError as exc:
logger.error("CRM update failed for contact_id=%s: %s", contact_id, exc)
return {
"success": False,
"email": email,
"contact_id": contact_id,
"error": f"CRM update failed: {exc}",
}

logger.info(
"Marked member agreement signed contact_id=%s email=%s",
contact_id,
email,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Redact signer email from logs to avoid PII leakage.

The new log lines emit raw email addresses. Please log submission_id/contact_id (or a masked hash) instead of full email.

🔒 Suggested log-hardening diff
- logger.error("CRM search failed for email=%s: %s", email, exc)+ logger.error("CRM search failed submission_id=%s: %s", submission_id, exc)
...
- logger.warning("No CRM contact found for email=%s", email)+ logger.warning("No CRM contact found submission_id=%s", submission_id)
...
- "Marked member agreement signed contact_id=%s email=%s",+ "Marked member agreement signed contact_id=%s submission_id=%s",
contact_id,
- email,+ submission_id,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/worker/src/five08/worker/crm/docuseal_processor.py` around lines 43 -
77, Replace raw email logging to avoid PII: update the logger calls in
docuseal_processor.py (the logger.error in the CRM search exception, the
logger.warning when no contact is found, the logger.error in the EspoAPIError
except block, and the final logger.info) to omit or mask the variable email and
instead log contact_id or submission_id (or a deterministic masked/hash of
email). Specifically, change references that pass email into logger.* (e.g., the
"CRM search failed for email=%s", "No CRM contact found for email=%s", "CRM
update failed for contact_id=%s: %s" and "Marked member agreement signed
contact_id=%s email=%s") so they only interpolate contact_id/submission_id or a
masked_email variable (created by hashing or redacting) and ensure error
payloads returned in dicts do not expose the raw email either.

@michaelmwu

Copy link
Copy Markdown
Member

Great, thanks for this, I'll take it from here.

We should be filtering on the template being the member agreement.

The documentation of what a webhook looks like is:

{
"event_type": "form.completed",
"timestamp": "2026-02-28T00:18:34Z",
"data": {
"id": 123,
"submission_id": 123,
"email": "__@508.dev",
"phone": null,
"name": null,
"ua": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36",
"ip": "196.249.122.246",
"sent_at": "2026-02-27T19:55:47.564Z",
"opened_at": "2026-02-27T20:00:04.861Z",
"completed_at": "2026-02-27T20:01:07.888Z",
"declined_at": null,
"created_at": "2026-02-27T19:53:56.461Z",
"updated_at": "2026-02-27T20:01:07.892Z",
"external_id": null,
"metadata": {},
"status": "completed",
"application_key": null,
"decline_reason": null,
"role": "Tachera Sasi",
"preferences": {
"send_email": true
},
"values": [
{
"field": "Date Field 3",
"value": "2026-02-27"
},
{
"field": "Signature Field 3",
"value": "https://docuseal.508.dev/file/___.png"
}
],
"documents": [
{
"name": "508.dev Member Agreement",
"url": "https://docuseal.508.dev/file/_____.dif"
}
],
"audit_log_url": "https://docuseal.508.dev/file/____.pdf",
"submission_url": "https://docuseal.508.dev/e/iKjYF8WWD5kWxC",
"template": {
"id": 69,
"name": "508.dev Member Agreement",
"external_id": null,
"created_at": "2026-02-27T19:47:21.222Z",
"updated_at": "2026-02-27T20:49:42.420Z",
"folder_name": "Signed Agreements / External Contracts"
},
"submission": {
"id": 416,
"audit_log_url": "https://docuseal.508.dev/file/_____.pdf",
"combined_document_url": null,
"status": "completed",
"url": "https://docuseal.508.dev/e/iKjYF8WWD5kWxC",
"variables": {},
"created_at": "2026-02-27T19:53:56.452Z"
}
}
}

@michaelmwu

Copy link
Copy Markdown
Member

#70

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@happysmile001@michaelmwu@kanbei65
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: add Docuseal webhook endpoint for member agreement (#48) by happysmile001 · Pull Request #67 · 508-dev/508-workflows · GitHub
Skip to content

feat: add Docuseal webhook endpoint for member agreement (#48) - #67

Closed
happysmile001 wants to merge 1 commit into
508-dev:mainfrom
happysmile001:feat/docuseal-webhook
Closed

feat: add Docuseal webhook endpoint for member agreement (#48)#67
happysmile001 wants to merge 1 commit into
508-dev:mainfrom
happysmile001:feat/docuseal-webhook

Conversation

@happysmile001

@happysmile001happysmile001 commented Feb 27, 2026

Copy link
Copy Markdown

Add /webhooks/docuseal endpoint that receives Docuseal form.completed events, looks up the signer by email in EspoCRM, and updates the cMemberAgreementSignedAt field on the contact.

NOTE: The CRM field name cMemberAgreementSignedAt is a placeholder following the existing c-prefix convention. The client should confirm the actual field name and adjust in docuseal_processor.py if needed.

Summary by CodeRabbit

  • New Features

    • Added Docuseal webhook integration that automatically processes completed form submissions.
    • System now automatically updates customer records in the CRM to mark member agreements as signed upon form completion.
  • Tests

    • Added comprehensive test coverage for webhook authorization, payload validation, and job handling.

Add /webhooks/docuseal endpoint that receives Docuseal form.completed
events, looks up the signer by email in EspoCRM, and updates the
cMemberAgreementSignedAt field on the contact.
NOTE: The CRM field name cMemberAgreementSignedAt is a placeholder
following the existing c-prefix convention. The client should confirm
the actual field name and adjust in docuseal_processor.py if needed.
@coderabbitai

coderabbitaiBot commented Feb 27, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Introduces a complete Docuseal webhook integration system. An API endpoint receives and validates webhook payloads, enqueues background jobs for form completion events, and processes them asynchronously to update CRM contact records with signed agreement timestamps.

Changes

Cohort / File(s)Summary
Webhook API Endpoint
apps/worker/src/five08/backend/api.py
New endpoint handler docuseal_webhook_handler that authenticates requests, validates JSON payloads against DocusealWebhookPayload, filters for "form.completed" events, and enqueues background jobs with idempotency keys. Returns 202 on success, 400/401/503 on errors.
Job System Integration
apps/worker/src/five08/worker/actors.py, apps/worker/src/five08/worker/jobs.py
Registers process_docuseal_agreement_job in the handler dispatch map and implements the job function that instantiates DocusealAgreementProcessor to process agreement data.
CRM Processor
apps/worker/src/five08/worker/crm/docuseal_processor.py
New DocusealAgreementProcessor class that queries EspoAPI to find contacts by email and updates their cMemberAgreementSignedAt field with completion timestamps. Includes error handling for CRM search/update failures.
Data Models
apps/worker/src/five08/worker/models.py
Adds DocusealSubmitter and DocusealWebhookPayload Pydantic models to validate incoming webhook data with fields for event type, timestamp, submitter identity, and completion details.
Tests
tests/unit/test_backend_api.py, tests/unit/test_worker_models.py
Comprehensive test coverage for webhook authorization, payload validation, job enqueueing with idempotency verification, event filtering, CRM failures, and model parsing.

Sequence Diagram

sequenceDiagram
actor Client as Docuseal<br/>(Client)
participant API as API<br/>Endpoint
participant Queue as Job<br/>Queue
participant Worker as Background<br/>Worker
participant CRM as EspoAPI<br/>(CRM)
Client->>API: POST /webhooks/docuseal<br/>(form.completed event)
API->>API: Validate auth & payload
API->>API: Filter event type
API->>Queue: enqueue_job<br/>(process_docuseal_agreement_job)
Queue-->>API: Job enqueued
API-->>Client: 202 Accepted
Worker->>Queue: Pick up job
Worker->>Worker: DocusealAgreementProcessor<br/>instantiate
Worker->>CRM: GET /Contact<br/>(search by email)
CRM-->>Worker: Contact found<br/>(id, details)
Worker->>CRM: PUT /Contact/{id}<br/>(set cMemberAgreementSignedAt)
CRM-->>Worker: Update successful
Worker->>Worker: Log completion<br/>success: true
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~35 minutes

Poem

🐰 A webhook hops into view,
With forms now complete and true,
Through queues and jobs, our CRM knows,
Agreements signed—the workflow flows! ✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe pull request title accurately and concisely describes the main change: adding a new Docuseal webhook endpoint for processing member agreement signatures.
Docstring Coverage✅ PassedDocstring coverage is 88.89% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
tests/unit/test_backend_api.py (1)

603-681: Add regression tests for blank/whitespace email and timestamp inputs.

Current tests don’t cover empty-string edge cases for data.email and completed_at/timestamp, which are critical for robust ingest validation.

🧪 Suggested additional tests
+def test_docuseal_webhook_rejects_blank_email(+ client: TestClient,+ auth_headers: dict[str, str],+) -> None:+ payload = {+ **_DOCUSEAL_PAYLOAD,+ "data": {**_DOCUSEAL_PAYLOAD["data"], "email": " "},+ }+ response = client.post("/webhooks/docuseal", json=payload, headers=auth_headers)+ assert response.status_code == 400+ assert response.json()["error"] == "invalid_payload"+++def test_docuseal_webhook_rejects_blank_completed_timestamp(+ client: TestClient,+ auth_headers: dict[str, str],+) -> None:+ payload = {+ **_DOCUSEAL_PAYLOAD,+ "timestamp": " ",+ "data": {**_DOCUSEAL_PAYLOAD["data"], "completed_at": " "},+ }+ response = client.post("/webhooks/docuseal", json=payload, headers=auth_headers)+ assert response.status_code == 400+ assert response.json()["error"] == "invalid_payload"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@tests/unit/test_backend_api.py` around lines 603 - 681, Add regression tests
to tests/unit/test_backend_api.py to cover blank/whitespace edge cases for the
Docuseal webhook: create two new tests (e.g.,
test_docuseal_webhook_rejects_blank_email and
test_docuseal_webhook_rejects_blank_timestamp) that POST to "/webhooks/docuseal"
using payloads based on _DOCUSEAL_PAYLOAD but with data.email set to "" and " " (whitespace) and completed_at/timestamp set to "" and " " respectively, and
assert the handler returns a 400 with error "invalid_payload" (mirror the style
of test_docuseal_webhook_rejects_invalid_payload). Ensure the tests use the
TestClient and auth_headers fixtures and maintain naming/structure consistent
with existing tests so they exercise the same validation logic in the webhook
handler.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/worker/src/five08/backend/api.py`:
- Around line 718-729: Normalize and validate the Docuseal fields before calling
enqueue_job: compute email = (submitter.email or "").strip() and completed_at =
submitter.completed_at or payload.timestamp (normalize/validate if it's a
string), then if email == "" or completed_at is falsy raise/return a 400 HTTP
error (e.g., HTTPException) and do not call enqueue_job; otherwise pass the
normalized email and completed_at into enqueue_job (keeping
idempotency_key=f"docuseal-agreement:{submitter.id}" and using
process_docuseal_agreement_job) so only non-blank values are enqueued.
In `@apps/worker/src/five08/worker/crm/docuseal_processor.py`:
- Around line 43-77: Replace raw email logging to avoid PII: update the logger
calls in docuseal_processor.py (the logger.error in the CRM search exception,
the logger.warning when no contact is found, the logger.error in the
EspoAPIError except block, and the final logger.info) to omit or mask the
variable email and instead log contact_id or submission_id (or a deterministic
masked/hash of email). Specifically, change references that pass email into
logger.* (e.g., the "CRM search failed for email=%s", "No CRM contact found for
email=%s", "CRM update failed for contact_id=%s: %s" and "Marked member
agreement signed contact_id=%s email=%s") so they only interpolate
contact_id/submission_id or a masked_email variable (created by hashing or
redacting) and ensure error payloads returned in dicts do not expose the raw
email either.
---
Nitpick comments:
In `@tests/unit/test_backend_api.py`:
- Around line 603-681: Add regression tests to tests/unit/test_backend_api.py to
cover blank/whitespace edge cases for the Docuseal webhook: create two new tests
(e.g., test_docuseal_webhook_rejects_blank_email and
test_docuseal_webhook_rejects_blank_timestamp) that POST to "/webhooks/docuseal"
using payloads based on _DOCUSEAL_PAYLOAD but with data.email set to "" and " " (whitespace) and completed_at/timestamp set to "" and " " respectively, and
assert the handler returns a 400 with error "invalid_payload" (mirror the style
of test_docuseal_webhook_rejects_invalid_payload). Ensure the tests use the
TestClient and auth_headers fixtures and maintain naming/structure consistent
with existing tests so they exercise the same validation logic in the webhook
handler.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9989f1d and 2269949.

📒 Files selected for processing (7)
  • apps/worker/src/five08/backend/api.py
  • apps/worker/src/five08/worker/actors.py
  • apps/worker/src/five08/worker/crm/docuseal_processor.py
  • apps/worker/src/five08/worker/jobs.py
  • apps/worker/src/five08/worker/models.py
  • tests/unit/test_backend_api.py
  • tests/unit/test_worker_models.py

Comment on lines +718 to +729
submitter = payload.data
completed_at = submitter.completed_at or payload.timestamp
queue = request.app.state.queue
try:
job: EnqueuedJob = await asyncio.to_thread(
enqueue_job,
queue=queue,
fn=process_docuseal_agreement_job,
args=(submitter.email, completed_at, submitter.id),
settings=settings,
idempotency_key=f"docuseal-agreement:{submitter.id}",
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Normalize and reject blank Docuseal identity/timestamp fields before enqueue.

submitter.email and completed_at/timestamp are used directly; whitespace/empty strings can still be queued. Add strip + non-empty validation before enqueue_job.

✅ Suggested validation hardening diff
 submitter = payload.data
- completed_at = submitter.completed_at or payload.timestamp+ email = submitter.email.strip().lower()+ if not email:+ return JSONResponse(+ {"error": "invalid_payload", "detail": "data.email is required"},+ status_code=400,+ )++ completed_at = (submitter.completed_at or payload.timestamp).strip()+ if not completed_at:+ return JSONResponse(+ {"error": "invalid_payload", "detail": "completed_at/timestamp is required"},+ status_code=400,+ )
queue = request.app.state.queue
try:
job: EnqueuedJob = await asyncio.to_thread(
enqueue_job,
queue=queue,
fn=process_docuseal_agreement_job,
- args=(submitter.email, completed_at, submitter.id),+ args=(email, completed_at, submitter.id),
settings=settings,
idempotency_key=f"docuseal-agreement:{submitter.id}",
)
As per coding guidelines "Worker ingest endpoints must validate input, persist jobs, enqueue, and return 202 quickly without performing long processing".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/worker/src/five08/backend/api.py` around lines 718 - 729, Normalize and
validate the Docuseal fields before calling enqueue_job: compute email =
(submitter.email or "").strip() and completed_at = submitter.completed_at or
payload.timestamp (normalize/validate if it's a string), then if email == "" or
completed_at is falsy raise/return a 400 HTTP error (e.g., HTTPException) and do
not call enqueue_job; otherwise pass the normalized email and completed_at into
enqueue_job (keeping idempotency_key=f"docuseal-agreement:{submitter.id}" and
using process_docuseal_agreement_job) so only non-blank values are enqueued.

Comment on lines +43 to +77
logger.error("CRM search failed for email=%s: %s", email, exc)
return {
"success": False,
"email": email,
"error": f"CRM search failed: {exc}",
}

contacts = result.get("list", [])
if not contacts:
logger.warning("No CRM contact found for email=%s", email)
return {"success": False, "email": email, "error": "contact_not_found"}

contact = contacts[0]
contact_id = contact["id"]

try:
self.api.request(
"PUT",
f"Contact/{contact_id}",
{"cMemberAgreementSignedAt": completed_at},
)
except EspoAPIError as exc:
logger.error("CRM update failed for contact_id=%s: %s", contact_id, exc)
return {
"success": False,
"email": email,
"contact_id": contact_id,
"error": f"CRM update failed: {exc}",
}

logger.info(
"Marked member agreement signed contact_id=%s email=%s",
contact_id,
email,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Redact signer email from logs to avoid PII leakage.

The new log lines emit raw email addresses. Please log submission_id/contact_id (or a masked hash) instead of full email.

🔒 Suggested log-hardening diff
- logger.error("CRM search failed for email=%s: %s", email, exc)+ logger.error("CRM search failed submission_id=%s: %s", submission_id, exc)
...
- logger.warning("No CRM contact found for email=%s", email)+ logger.warning("No CRM contact found submission_id=%s", submission_id)
...
- "Marked member agreement signed contact_id=%s email=%s",+ "Marked member agreement signed contact_id=%s submission_id=%s",
contact_id,
- email,+ submission_id,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/worker/src/five08/worker/crm/docuseal_processor.py` around lines 43 -
77, Replace raw email logging to avoid PII: update the logger calls in
docuseal_processor.py (the logger.error in the CRM search exception, the
logger.warning when no contact is found, the logger.error in the EspoAPIError
except block, and the final logger.info) to omit or mask the variable email and
instead log contact_id or submission_id (or a deterministic masked/hash of
email). Specifically, change references that pass email into logger.* (e.g., the
"CRM search failed for email=%s", "No CRM contact found for email=%s", "CRM
update failed for contact_id=%s: %s" and "Marked member agreement signed
contact_id=%s email=%s") so they only interpolate contact_id/submission_id or a
masked_email variable (created by hashing or redacting) and ensure error
payloads returned in dicts do not expose the raw email either.

@michaelmwu

Copy link
Copy Markdown
Member

Great, thanks for this, I'll take it from here.

We should be filtering on the template being the member agreement.

The documentation of what a webhook looks like is:

{
"event_type": "form.completed",
"timestamp": "2026-02-28T00:18:34Z",
"data": {
"id": 123,
"submission_id": 123,
"email": "__@508.dev",
"phone": null,
"name": null,
"ua": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36",
"ip": "196.249.122.246",
"sent_at": "2026-02-27T19:55:47.564Z",
"opened_at": "2026-02-27T20:00:04.861Z",
"completed_at": "2026-02-27T20:01:07.888Z",
"declined_at": null,
"created_at": "2026-02-27T19:53:56.461Z",
"updated_at": "2026-02-27T20:01:07.892Z",
"external_id": null,
"metadata": {},
"status": "completed",
"application_key": null,
"decline_reason": null,
"role": "Tachera Sasi",
"preferences": {
"send_email": true
},
"values": [
{
"field": "Date Field 3",
"value": "2026-02-27"
},
{
"field": "Signature Field 3",
"value": "https://docuseal.508.dev/file/___.png"
}
],
"documents": [
{
"name": "508.dev Member Agreement",
"url": "https://docuseal.508.dev/file/_____.dif"
}
],
"audit_log_url": "https://docuseal.508.dev/file/____.pdf",
"submission_url": "https://docuseal.508.dev/e/iKjYF8WWD5kWxC",
"template": {
"id": 69,
"name": "508.dev Member Agreement",
"external_id": null,
"created_at": "2026-02-27T19:47:21.222Z",
"updated_at": "2026-02-27T20:49:42.420Z",
"folder_name": "Signed Agreements / External Contracts"
},
"submission": {
"id": 416,
"audit_log_url": "https://docuseal.508.dev/file/_____.pdf",
"combined_document_url": null,
"status": "completed",
"url": "https://docuseal.508.dev/e/iKjYF8WWD5kWxC",
"variables": {},
"created_at": "2026-02-27T19:53:56.452Z"
}
}
}

@michaelmwu

Copy link
Copy Markdown
Member

#70

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@happysmile001@michaelmwu@kanbei65
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: add Docuseal webhook endpoint for member agreement (#48) by happysmile001 · Pull Request #67 · 508-dev/508-workflows · GitHub
Skip to content

feat: add Docuseal webhook endpoint for member agreement (#48) - #67

Closed
happysmile001 wants to merge 1 commit into
508-dev:mainfrom
happysmile001:feat/docuseal-webhook
Closed

feat: add Docuseal webhook endpoint for member agreement (#48)#67
happysmile001 wants to merge 1 commit into
508-dev:mainfrom
happysmile001:feat/docuseal-webhook

Conversation

@happysmile001

@happysmile001happysmile001 commented Feb 27, 2026

Copy link
Copy Markdown

Add /webhooks/docuseal endpoint that receives Docuseal form.completed events, looks up the signer by email in EspoCRM, and updates the cMemberAgreementSignedAt field on the contact.

NOTE: The CRM field name cMemberAgreementSignedAt is a placeholder following the existing c-prefix convention. The client should confirm the actual field name and adjust in docuseal_processor.py if needed.

Summary by CodeRabbit

  • New Features

    • Added Docuseal webhook integration that automatically processes completed form submissions.
    • System now automatically updates customer records in the CRM to mark member agreements as signed upon form completion.
  • Tests

    • Added comprehensive test coverage for webhook authorization, payload validation, and job handling.

Add /webhooks/docuseal endpoint that receives Docuseal form.completed
events, looks up the signer by email in EspoCRM, and updates the
cMemberAgreementSignedAt field on the contact.
NOTE: The CRM field name cMemberAgreementSignedAt is a placeholder
following the existing c-prefix convention. The client should confirm
the actual field name and adjust in docuseal_processor.py if needed.
@coderabbitai

coderabbitaiBot commented Feb 27, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Introduces a complete Docuseal webhook integration system. An API endpoint receives and validates webhook payloads, enqueues background jobs for form completion events, and processes them asynchronously to update CRM contact records with signed agreement timestamps.

Changes

Cohort / File(s)Summary
Webhook API Endpoint
apps/worker/src/five08/backend/api.py
New endpoint handler docuseal_webhook_handler that authenticates requests, validates JSON payloads against DocusealWebhookPayload, filters for "form.completed" events, and enqueues background jobs with idempotency keys. Returns 202 on success, 400/401/503 on errors.
Job System Integration
apps/worker/src/five08/worker/actors.py, apps/worker/src/five08/worker/jobs.py
Registers process_docuseal_agreement_job in the handler dispatch map and implements the job function that instantiates DocusealAgreementProcessor to process agreement data.
CRM Processor
apps/worker/src/five08/worker/crm/docuseal_processor.py
New DocusealAgreementProcessor class that queries EspoAPI to find contacts by email and updates their cMemberAgreementSignedAt field with completion timestamps. Includes error handling for CRM search/update failures.
Data Models
apps/worker/src/five08/worker/models.py
Adds DocusealSubmitter and DocusealWebhookPayload Pydantic models to validate incoming webhook data with fields for event type, timestamp, submitter identity, and completion details.
Tests
tests/unit/test_backend_api.py, tests/unit/test_worker_models.py
Comprehensive test coverage for webhook authorization, payload validation, job enqueueing with idempotency verification, event filtering, CRM failures, and model parsing.

Sequence Diagram

sequenceDiagram
actor Client as Docuseal<br/>(Client)
participant API as API<br/>Endpoint
participant Queue as Job<br/>Queue
participant Worker as Background<br/>Worker
participant CRM as EspoAPI<br/>(CRM)
Client->>API: POST /webhooks/docuseal<br/>(form.completed event)
API->>API: Validate auth & payload
API->>API: Filter event type
API->>Queue: enqueue_job<br/>(process_docuseal_agreement_job)
Queue-->>API: Job enqueued
API-->>Client: 202 Accepted
Worker->>Queue: Pick up job
Worker->>Worker: DocusealAgreementProcessor<br/>instantiate
Worker->>CRM: GET /Contact<br/>(search by email)
CRM-->>Worker: Contact found<br/>(id, details)
Worker->>CRM: PUT /Contact/{id}<br/>(set cMemberAgreementSignedAt)
CRM-->>Worker: Update successful
Worker->>Worker: Log completion<br/>success: true
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~35 minutes

Poem

🐰 A webhook hops into view,
With forms now complete and true,
Through queues and jobs, our CRM knows,
Agreements signed—the workflow flows! ✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe pull request title accurately and concisely describes the main change: adding a new Docuseal webhook endpoint for processing member agreement signatures.
Docstring Coverage✅ PassedDocstring coverage is 88.89% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
tests/unit/test_backend_api.py (1)

603-681: Add regression tests for blank/whitespace email and timestamp inputs.

Current tests don’t cover empty-string edge cases for data.email and completed_at/timestamp, which are critical for robust ingest validation.

🧪 Suggested additional tests
+def test_docuseal_webhook_rejects_blank_email(+ client: TestClient,+ auth_headers: dict[str, str],+) -> None:+ payload = {+ **_DOCUSEAL_PAYLOAD,+ "data": {**_DOCUSEAL_PAYLOAD["data"], "email": " "},+ }+ response = client.post("/webhooks/docuseal", json=payload, headers=auth_headers)+ assert response.status_code == 400+ assert response.json()["error"] == "invalid_payload"+++def test_docuseal_webhook_rejects_blank_completed_timestamp(+ client: TestClient,+ auth_headers: dict[str, str],+) -> None:+ payload = {+ **_DOCUSEAL_PAYLOAD,+ "timestamp": " ",+ "data": {**_DOCUSEAL_PAYLOAD["data"], "completed_at": " "},+ }+ response = client.post("/webhooks/docuseal", json=payload, headers=auth_headers)+ assert response.status_code == 400+ assert response.json()["error"] == "invalid_payload"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@tests/unit/test_backend_api.py` around lines 603 - 681, Add regression tests
to tests/unit/test_backend_api.py to cover blank/whitespace edge cases for the
Docuseal webhook: create two new tests (e.g.,
test_docuseal_webhook_rejects_blank_email and
test_docuseal_webhook_rejects_blank_timestamp) that POST to "/webhooks/docuseal"
using payloads based on _DOCUSEAL_PAYLOAD but with data.email set to "" and " " (whitespace) and completed_at/timestamp set to "" and " " respectively, and
assert the handler returns a 400 with error "invalid_payload" (mirror the style
of test_docuseal_webhook_rejects_invalid_payload). Ensure the tests use the
TestClient and auth_headers fixtures and maintain naming/structure consistent
with existing tests so they exercise the same validation logic in the webhook
handler.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/worker/src/five08/backend/api.py`:
- Around line 718-729: Normalize and validate the Docuseal fields before calling
enqueue_job: compute email = (submitter.email or "").strip() and completed_at =
submitter.completed_at or payload.timestamp (normalize/validate if it's a
string), then if email == "" or completed_at is falsy raise/return a 400 HTTP
error (e.g., HTTPException) and do not call enqueue_job; otherwise pass the
normalized email and completed_at into enqueue_job (keeping
idempotency_key=f"docuseal-agreement:{submitter.id}" and using
process_docuseal_agreement_job) so only non-blank values are enqueued.
In `@apps/worker/src/five08/worker/crm/docuseal_processor.py`:
- Around line 43-77: Replace raw email logging to avoid PII: update the logger
calls in docuseal_processor.py (the logger.error in the CRM search exception,
the logger.warning when no contact is found, the logger.error in the
EspoAPIError except block, and the final logger.info) to omit or mask the
variable email and instead log contact_id or submission_id (or a deterministic
masked/hash of email). Specifically, change references that pass email into
logger.* (e.g., the "CRM search failed for email=%s", "No CRM contact found for
email=%s", "CRM update failed for contact_id=%s: %s" and "Marked member
agreement signed contact_id=%s email=%s") so they only interpolate
contact_id/submission_id or a masked_email variable (created by hashing or
redacting) and ensure error payloads returned in dicts do not expose the raw
email either.
---
Nitpick comments:
In `@tests/unit/test_backend_api.py`:
- Around line 603-681: Add regression tests to tests/unit/test_backend_api.py to
cover blank/whitespace edge cases for the Docuseal webhook: create two new tests
(e.g., test_docuseal_webhook_rejects_blank_email and
test_docuseal_webhook_rejects_blank_timestamp) that POST to "/webhooks/docuseal"
using payloads based on _DOCUSEAL_PAYLOAD but with data.email set to "" and " " (whitespace) and completed_at/timestamp set to "" and " " respectively, and
assert the handler returns a 400 with error "invalid_payload" (mirror the style
of test_docuseal_webhook_rejects_invalid_payload). Ensure the tests use the
TestClient and auth_headers fixtures and maintain naming/structure consistent
with existing tests so they exercise the same validation logic in the webhook
handler.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9989f1d and 2269949.

📒 Files selected for processing (7)
  • apps/worker/src/five08/backend/api.py
  • apps/worker/src/five08/worker/actors.py
  • apps/worker/src/five08/worker/crm/docuseal_processor.py
  • apps/worker/src/five08/worker/jobs.py
  • apps/worker/src/five08/worker/models.py
  • tests/unit/test_backend_api.py
  • tests/unit/test_worker_models.py

Comment on lines +718 to +729
submitter = payload.data
completed_at = submitter.completed_at or payload.timestamp
queue = request.app.state.queue
try:
job: EnqueuedJob = await asyncio.to_thread(
enqueue_job,
queue=queue,
fn=process_docuseal_agreement_job,
args=(submitter.email, completed_at, submitter.id),
settings=settings,
idempotency_key=f"docuseal-agreement:{submitter.id}",
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Normalize and reject blank Docuseal identity/timestamp fields before enqueue.

submitter.email and completed_at/timestamp are used directly; whitespace/empty strings can still be queued. Add strip + non-empty validation before enqueue_job.

✅ Suggested validation hardening diff
 submitter = payload.data
- completed_at = submitter.completed_at or payload.timestamp+ email = submitter.email.strip().lower()+ if not email:+ return JSONResponse(+ {"error": "invalid_payload", "detail": "data.email is required"},+ status_code=400,+ )++ completed_at = (submitter.completed_at or payload.timestamp).strip()+ if not completed_at:+ return JSONResponse(+ {"error": "invalid_payload", "detail": "completed_at/timestamp is required"},+ status_code=400,+ )
queue = request.app.state.queue
try:
job: EnqueuedJob = await asyncio.to_thread(
enqueue_job,
queue=queue,
fn=process_docuseal_agreement_job,
- args=(submitter.email, completed_at, submitter.id),+ args=(email, completed_at, submitter.id),
settings=settings,
idempotency_key=f"docuseal-agreement:{submitter.id}",
)
As per coding guidelines "Worker ingest endpoints must validate input, persist jobs, enqueue, and return 202 quickly without performing long processing".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/worker/src/five08/backend/api.py` around lines 718 - 729, Normalize and
validate the Docuseal fields before calling enqueue_job: compute email =
(submitter.email or "").strip() and completed_at = submitter.completed_at or
payload.timestamp (normalize/validate if it's a string), then if email == "" or
completed_at is falsy raise/return a 400 HTTP error (e.g., HTTPException) and do
not call enqueue_job; otherwise pass the normalized email and completed_at into
enqueue_job (keeping idempotency_key=f"docuseal-agreement:{submitter.id}" and
using process_docuseal_agreement_job) so only non-blank values are enqueued.

Comment on lines +43 to +77
logger.error("CRM search failed for email=%s: %s", email, exc)
return {
"success": False,
"email": email,
"error": f"CRM search failed: {exc}",
}

contacts = result.get("list", [])
if not contacts:
logger.warning("No CRM contact found for email=%s", email)
return {"success": False, "email": email, "error": "contact_not_found"}

contact = contacts[0]
contact_id = contact["id"]

try:
self.api.request(
"PUT",
f"Contact/{contact_id}",
{"cMemberAgreementSignedAt": completed_at},
)
except EspoAPIError as exc:
logger.error("CRM update failed for contact_id=%s: %s", contact_id, exc)
return {
"success": False,
"email": email,
"contact_id": contact_id,
"error": f"CRM update failed: {exc}",
}

logger.info(
"Marked member agreement signed contact_id=%s email=%s",
contact_id,
email,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Redact signer email from logs to avoid PII leakage.

The new log lines emit raw email addresses. Please log submission_id/contact_id (or a masked hash) instead of full email.

🔒 Suggested log-hardening diff
- logger.error("CRM search failed for email=%s: %s", email, exc)+ logger.error("CRM search failed submission_id=%s: %s", submission_id, exc)
...
- logger.warning("No CRM contact found for email=%s", email)+ logger.warning("No CRM contact found submission_id=%s", submission_id)
...
- "Marked member agreement signed contact_id=%s email=%s",+ "Marked member agreement signed contact_id=%s submission_id=%s",
contact_id,
- email,+ submission_id,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/worker/src/five08/worker/crm/docuseal_processor.py` around lines 43 -
77, Replace raw email logging to avoid PII: update the logger calls in
docuseal_processor.py (the logger.error in the CRM search exception, the
logger.warning when no contact is found, the logger.error in the EspoAPIError
except block, and the final logger.info) to omit or mask the variable email and
instead log contact_id or submission_id (or a deterministic masked/hash of
email). Specifically, change references that pass email into logger.* (e.g., the
"CRM search failed for email=%s", "No CRM contact found for email=%s", "CRM
update failed for contact_id=%s: %s" and "Marked member agreement signed
contact_id=%s email=%s") so they only interpolate contact_id/submission_id or a
masked_email variable (created by hashing or redacting) and ensure error
payloads returned in dicts do not expose the raw email either.

@michaelmwu

Copy link
Copy Markdown
Member

Great, thanks for this, I'll take it from here.

We should be filtering on the template being the member agreement.

The documentation of what a webhook looks like is:

{
"event_type": "form.completed",
"timestamp": "2026-02-28T00:18:34Z",
"data": {
"id": 123,
"submission_id": 123,
"email": "__@508.dev",
"phone": null,
"name": null,
"ua": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36",
"ip": "196.249.122.246",
"sent_at": "2026-02-27T19:55:47.564Z",
"opened_at": "2026-02-27T20:00:04.861Z",
"completed_at": "2026-02-27T20:01:07.888Z",
"declined_at": null,
"created_at": "2026-02-27T19:53:56.461Z",
"updated_at": "2026-02-27T20:01:07.892Z",
"external_id": null,
"metadata": {},
"status": "completed",
"application_key": null,
"decline_reason": null,
"role": "Tachera Sasi",
"preferences": {
"send_email": true
},
"values": [
{
"field": "Date Field 3",
"value": "2026-02-27"
},
{
"field": "Signature Field 3",
"value": "https://docuseal.508.dev/file/___.png"
}
],
"documents": [
{
"name": "508.dev Member Agreement",
"url": "https://docuseal.508.dev/file/_____.dif"
}
],
"audit_log_url": "https://docuseal.508.dev/file/____.pdf",
"submission_url": "https://docuseal.508.dev/e/iKjYF8WWD5kWxC",
"template": {
"id": 69,
"name": "508.dev Member Agreement",
"external_id": null,
"created_at": "2026-02-27T19:47:21.222Z",
"updated_at": "2026-02-27T20:49:42.420Z",
"folder_name": "Signed Agreements / External Contracts"
},
"submission": {
"id": 416,
"audit_log_url": "https://docuseal.508.dev/file/_____.pdf",
"combined_document_url": null,
"status": "completed",
"url": "https://docuseal.508.dev/e/iKjYF8WWD5kWxC",
"variables": {},
"created_at": "2026-02-27T19:53:56.452Z"
}
}
}

@michaelmwu

Copy link
Copy Markdown
Member

#70

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@happysmile001@michaelmwu@kanbei65
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat: add Docuseal webhook endpoint for member agreement (#48) by happysmile001 · Pull Request #67 · 508-dev/508-workflows · GitHub
Skip to content

feat: add Docuseal webhook endpoint for member agreement (#48) - #67

Closed
happysmile001 wants to merge 1 commit into
508-dev:mainfrom
happysmile001:feat/docuseal-webhook
Closed

feat: add Docuseal webhook endpoint for member agreement (#48)#67
happysmile001 wants to merge 1 commit into
508-dev:mainfrom
happysmile001:feat/docuseal-webhook

Conversation

@happysmile001

@happysmile001happysmile001 commented Feb 27, 2026

Copy link
Copy Markdown

Add /webhooks/docuseal endpoint that receives Docuseal form.completed events, looks up the signer by email in EspoCRM, and updates the cMemberAgreementSignedAt field on the contact.

NOTE: The CRM field name cMemberAgreementSignedAt is a placeholder following the existing c-prefix convention. The client should confirm the actual field name and adjust in docuseal_processor.py if needed.

Summary by CodeRabbit

  • New Features

    • Added Docuseal webhook integration that automatically processes completed form submissions.
    • System now automatically updates customer records in the CRM to mark member agreements as signed upon form completion.
  • Tests

    • Added comprehensive test coverage for webhook authorization, payload validation, and job handling.

Add /webhooks/docuseal endpoint that receives Docuseal form.completed
events, looks up the signer by email in EspoCRM, and updates the
cMemberAgreementSignedAt field on the contact.
NOTE: The CRM field name cMemberAgreementSignedAt is a placeholder
following the existing c-prefix convention. The client should confirm
the actual field name and adjust in docuseal_processor.py if needed.
@coderabbitai

coderabbitaiBot commented Feb 27, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Introduces a complete Docuseal webhook integration system. An API endpoint receives and validates webhook payloads, enqueues background jobs for form completion events, and processes them asynchronously to update CRM contact records with signed agreement timestamps.

Changes

Cohort / File(s)Summary
Webhook API Endpoint
apps/worker/src/five08/backend/api.py
New endpoint handler docuseal_webhook_handler that authenticates requests, validates JSON payloads against DocusealWebhookPayload, filters for "form.completed" events, and enqueues background jobs with idempotency keys. Returns 202 on success, 400/401/503 on errors.
Job System Integration
apps/worker/src/five08/worker/actors.py, apps/worker/src/five08/worker/jobs.py
Registers process_docuseal_agreement_job in the handler dispatch map and implements the job function that instantiates DocusealAgreementProcessor to process agreement data.
CRM Processor
apps/worker/src/five08/worker/crm/docuseal_processor.py
New DocusealAgreementProcessor class that queries EspoAPI to find contacts by email and updates their cMemberAgreementSignedAt field with completion timestamps. Includes error handling for CRM search/update failures.
Data Models
apps/worker/src/five08/worker/models.py
Adds DocusealSubmitter and DocusealWebhookPayload Pydantic models to validate incoming webhook data with fields for event type, timestamp, submitter identity, and completion details.
Tests
tests/unit/test_backend_api.py, tests/unit/test_worker_models.py
Comprehensive test coverage for webhook authorization, payload validation, job enqueueing with idempotency verification, event filtering, CRM failures, and model parsing.

Sequence Diagram

sequenceDiagram
actor Client as Docuseal<br/>(Client)
participant API as API<br/>Endpoint
participant Queue as Job<br/>Queue
participant Worker as Background<br/>Worker
participant CRM as EspoAPI<br/>(CRM)
Client->>API: POST /webhooks/docuseal<br/>(form.completed event)
API->>API: Validate auth & payload
API->>API: Filter event type
API->>Queue: enqueue_job<br/>(process_docuseal_agreement_job)
Queue-->>API: Job enqueued
API-->>Client: 202 Accepted
Worker->>Queue: Pick up job
Worker->>Worker: DocusealAgreementProcessor<br/>instantiate
Worker->>CRM: GET /Contact<br/>(search by email)
CRM-->>Worker: Contact found<br/>(id, details)
Worker->>CRM: PUT /Contact/{id}<br/>(set cMemberAgreementSignedAt)
CRM-->>Worker: Update successful
Worker->>Worker: Log completion<br/>success: true
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~35 minutes

Poem

🐰 A webhook hops into view,
With forms now complete and true,
Through queues and jobs, our CRM knows,
Agreements signed—the workflow flows! ✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe pull request title accurately and concisely describes the main change: adding a new Docuseal webhook endpoint for processing member agreement signatures.
Docstring Coverage✅ PassedDocstring coverage is 88.89% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
tests/unit/test_backend_api.py (1)

603-681: Add regression tests for blank/whitespace email and timestamp inputs.

Current tests don’t cover empty-string edge cases for data.email and completed_at/timestamp, which are critical for robust ingest validation.

🧪 Suggested additional tests
+def test_docuseal_webhook_rejects_blank_email(+ client: TestClient,+ auth_headers: dict[str, str],+) -> None:+ payload = {+ **_DOCUSEAL_PAYLOAD,+ "data": {**_DOCUSEAL_PAYLOAD["data"], "email": " "},+ }+ response = client.post("/webhooks/docuseal", json=payload, headers=auth_headers)+ assert response.status_code == 400+ assert response.json()["error"] == "invalid_payload"+++def test_docuseal_webhook_rejects_blank_completed_timestamp(+ client: TestClient,+ auth_headers: dict[str, str],+) -> None:+ payload = {+ **_DOCUSEAL_PAYLOAD,+ "timestamp": " ",+ "data": {**_DOCUSEAL_PAYLOAD["data"], "completed_at": " "},+ }+ response = client.post("/webhooks/docuseal", json=payload, headers=auth_headers)+ assert response.status_code == 400+ assert response.json()["error"] == "invalid_payload"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@tests/unit/test_backend_api.py` around lines 603 - 681, Add regression tests
to tests/unit/test_backend_api.py to cover blank/whitespace edge cases for the
Docuseal webhook: create two new tests (e.g.,
test_docuseal_webhook_rejects_blank_email and
test_docuseal_webhook_rejects_blank_timestamp) that POST to "/webhooks/docuseal"
using payloads based on _DOCUSEAL_PAYLOAD but with data.email set to "" and " " (whitespace) and completed_at/timestamp set to "" and " " respectively, and
assert the handler returns a 400 with error "invalid_payload" (mirror the style
of test_docuseal_webhook_rejects_invalid_payload). Ensure the tests use the
TestClient and auth_headers fixtures and maintain naming/structure consistent
with existing tests so they exercise the same validation logic in the webhook
handler.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/worker/src/five08/backend/api.py`:
- Around line 718-729: Normalize and validate the Docuseal fields before calling
enqueue_job: compute email = (submitter.email or "").strip() and completed_at =
submitter.completed_at or payload.timestamp (normalize/validate if it's a
string), then if email == "" or completed_at is falsy raise/return a 400 HTTP
error (e.g., HTTPException) and do not call enqueue_job; otherwise pass the
normalized email and completed_at into enqueue_job (keeping
idempotency_key=f"docuseal-agreement:{submitter.id}" and using
process_docuseal_agreement_job) so only non-blank values are enqueued.
In `@apps/worker/src/five08/worker/crm/docuseal_processor.py`:
- Around line 43-77: Replace raw email logging to avoid PII: update the logger
calls in docuseal_processor.py (the logger.error in the CRM search exception,
the logger.warning when no contact is found, the logger.error in the
EspoAPIError except block, and the final logger.info) to omit or mask the
variable email and instead log contact_id or submission_id (or a deterministic
masked/hash of email). Specifically, change references that pass email into
logger.* (e.g., the "CRM search failed for email=%s", "No CRM contact found for
email=%s", "CRM update failed for contact_id=%s: %s" and "Marked member
agreement signed contact_id=%s email=%s") so they only interpolate
contact_id/submission_id or a masked_email variable (created by hashing or
redacting) and ensure error payloads returned in dicts do not expose the raw
email either.
---
Nitpick comments:
In `@tests/unit/test_backend_api.py`:
- Around line 603-681: Add regression tests to tests/unit/test_backend_api.py to
cover blank/whitespace edge cases for the Docuseal webhook: create two new tests
(e.g., test_docuseal_webhook_rejects_blank_email and
test_docuseal_webhook_rejects_blank_timestamp) that POST to "/webhooks/docuseal"
using payloads based on _DOCUSEAL_PAYLOAD but with data.email set to "" and " " (whitespace) and completed_at/timestamp set to "" and " " respectively, and
assert the handler returns a 400 with error "invalid_payload" (mirror the style
of test_docuseal_webhook_rejects_invalid_payload). Ensure the tests use the
TestClient and auth_headers fixtures and maintain naming/structure consistent
with existing tests so they exercise the same validation logic in the webhook
handler.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9989f1d and 2269949.

📒 Files selected for processing (7)
  • apps/worker/src/five08/backend/api.py
  • apps/worker/src/five08/worker/actors.py
  • apps/worker/src/five08/worker/crm/docuseal_processor.py
  • apps/worker/src/five08/worker/jobs.py
  • apps/worker/src/five08/worker/models.py
  • tests/unit/test_backend_api.py
  • tests/unit/test_worker_models.py

Comment on lines +718 to +729
submitter = payload.data
completed_at = submitter.completed_at or payload.timestamp
queue = request.app.state.queue
try:
job: EnqueuedJob = await asyncio.to_thread(
enqueue_job,
queue=queue,
fn=process_docuseal_agreement_job,
args=(submitter.email, completed_at, submitter.id),
settings=settings,
idempotency_key=f"docuseal-agreement:{submitter.id}",
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Normalize and reject blank Docuseal identity/timestamp fields before enqueue.

submitter.email and completed_at/timestamp are used directly; whitespace/empty strings can still be queued. Add strip + non-empty validation before enqueue_job.

✅ Suggested validation hardening diff
 submitter = payload.data
- completed_at = submitter.completed_at or payload.timestamp+ email = submitter.email.strip().lower()+ if not email:+ return JSONResponse(+ {"error": "invalid_payload", "detail": "data.email is required"},+ status_code=400,+ )++ completed_at = (submitter.completed_at or payload.timestamp).strip()+ if not completed_at:+ return JSONResponse(+ {"error": "invalid_payload", "detail": "completed_at/timestamp is required"},+ status_code=400,+ )
queue = request.app.state.queue
try:
job: EnqueuedJob = await asyncio.to_thread(
enqueue_job,
queue=queue,
fn=process_docuseal_agreement_job,
- args=(submitter.email, completed_at, submitter.id),+ args=(email, completed_at, submitter.id),
settings=settings,
idempotency_key=f"docuseal-agreement:{submitter.id}",
)
As per coding guidelines "Worker ingest endpoints must validate input, persist jobs, enqueue, and return 202 quickly without performing long processing".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/worker/src/five08/backend/api.py` around lines 718 - 729, Normalize and
validate the Docuseal fields before calling enqueue_job: compute email =
(submitter.email or "").strip() and completed_at = submitter.completed_at or
payload.timestamp (normalize/validate if it's a string), then if email == "" or
completed_at is falsy raise/return a 400 HTTP error (e.g., HTTPException) and do
not call enqueue_job; otherwise pass the normalized email and completed_at into
enqueue_job (keeping idempotency_key=f"docuseal-agreement:{submitter.id}" and
using process_docuseal_agreement_job) so only non-blank values are enqueued.

Comment on lines +43 to +77
logger.error("CRM search failed for email=%s: %s", email, exc)
return {
"success": False,
"email": email,
"error": f"CRM search failed: {exc}",
}

contacts = result.get("list", [])
if not contacts:
logger.warning("No CRM contact found for email=%s", email)
return {"success": False, "email": email, "error": "contact_not_found"}

contact = contacts[0]
contact_id = contact["id"]

try:
self.api.request(
"PUT",
f"Contact/{contact_id}",
{"cMemberAgreementSignedAt": completed_at},
)
except EspoAPIError as exc:
logger.error("CRM update failed for contact_id=%s: %s", contact_id, exc)
return {
"success": False,
"email": email,
"contact_id": contact_id,
"error": f"CRM update failed: {exc}",
}

logger.info(
"Marked member agreement signed contact_id=%s email=%s",
contact_id,
email,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Redact signer email from logs to avoid PII leakage.

The new log lines emit raw email addresses. Please log submission_id/contact_id (or a masked hash) instead of full email.

🔒 Suggested log-hardening diff
- logger.error("CRM search failed for email=%s: %s", email, exc)+ logger.error("CRM search failed submission_id=%s: %s", submission_id, exc)
...
- logger.warning("No CRM contact found for email=%s", email)+ logger.warning("No CRM contact found submission_id=%s", submission_id)
...
- "Marked member agreement signed contact_id=%s email=%s",+ "Marked member agreement signed contact_id=%s submission_id=%s",
contact_id,
- email,+ submission_id,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/worker/src/five08/worker/crm/docuseal_processor.py` around lines 43 -
77, Replace raw email logging to avoid PII: update the logger calls in
docuseal_processor.py (the logger.error in the CRM search exception, the
logger.warning when no contact is found, the logger.error in the EspoAPIError
except block, and the final logger.info) to omit or mask the variable email and
instead log contact_id or submission_id (or a deterministic masked/hash of
email). Specifically, change references that pass email into logger.* (e.g., the
"CRM search failed for email=%s", "No CRM contact found for email=%s", "CRM
update failed for contact_id=%s: %s" and "Marked member agreement signed
contact_id=%s email=%s") so they only interpolate contact_id/submission_id or a
masked_email variable (created by hashing or redacting) and ensure error
payloads returned in dicts do not expose the raw email either.

@michaelmwu

Copy link
Copy Markdown
Member

Great, thanks for this, I'll take it from here.

We should be filtering on the template being the member agreement.

The documentation of what a webhook looks like is:

{
"event_type": "form.completed",
"timestamp": "2026-02-28T00:18:34Z",
"data": {
"id": 123,
"submission_id": 123,
"email": "__@508.dev",
"phone": null,
"name": null,
"ua": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36",
"ip": "196.249.122.246",
"sent_at": "2026-02-27T19:55:47.564Z",
"opened_at": "2026-02-27T20:00:04.861Z",
"completed_at": "2026-02-27T20:01:07.888Z",
"declined_at": null,
"created_at": "2026-02-27T19:53:56.461Z",
"updated_at": "2026-02-27T20:01:07.892Z",
"external_id": null,
"metadata": {},
"status": "completed",
"application_key": null,
"decline_reason": null,
"role": "Tachera Sasi",
"preferences": {
"send_email": true
},
"values": [
{
"field": "Date Field 3",
"value": "2026-02-27"
},
{
"field": "Signature Field 3",
"value": "https://docuseal.508.dev/file/___.png"
}
],
"documents": [
{
"name": "508.dev Member Agreement",
"url": "https://docuseal.508.dev/file/_____.dif"
}
],
"audit_log_url": "https://docuseal.508.dev/file/____.pdf",
"submission_url": "https://docuseal.508.dev/e/iKjYF8WWD5kWxC",
"template": {
"id": 69,
"name": "508.dev Member Agreement",
"external_id": null,
"created_at": "2026-02-27T19:47:21.222Z",
"updated_at": "2026-02-27T20:49:42.420Z",
"folder_name": "Signed Agreements / External Contracts"
},
"submission": {
"id": 416,
"audit_log_url": "https://docuseal.508.dev/file/_____.pdf",
"combined_document_url": null,
"status": "completed",
"url": "https://docuseal.508.dev/e/iKjYF8WWD5kWxC",
"variables": {},
"created_at": "2026-02-27T19:53:56.452Z"
}
}
}

@michaelmwu

Copy link
Copy Markdown
Member

#70

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@happysmile001@michaelmwu@kanbei65
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: add Docuseal webhook endpoint for member agreement (#48) by happysmile001 · Pull Request #67 · 508-dev/508-workflows · GitHub
Skip to content

feat: add Docuseal webhook endpoint for member agreement (#48) - #67

Closed
happysmile001 wants to merge 1 commit into
508-dev:mainfrom
happysmile001:feat/docuseal-webhook
Closed

feat: add Docuseal webhook endpoint for member agreement (#48)#67
happysmile001 wants to merge 1 commit into
508-dev:mainfrom
happysmile001:feat/docuseal-webhook

Conversation

@happysmile001

@happysmile001happysmile001 commented Feb 27, 2026

Copy link
Copy Markdown

Add /webhooks/docuseal endpoint that receives Docuseal form.completed events, looks up the signer by email in EspoCRM, and updates the cMemberAgreementSignedAt field on the contact.

NOTE: The CRM field name cMemberAgreementSignedAt is a placeholder following the existing c-prefix convention. The client should confirm the actual field name and adjust in docuseal_processor.py if needed.

Summary by CodeRabbit

  • New Features

    • Added Docuseal webhook integration that automatically processes completed form submissions.
    • System now automatically updates customer records in the CRM to mark member agreements as signed upon form completion.
  • Tests

    • Added comprehensive test coverage for webhook authorization, payload validation, and job handling.

Add /webhooks/docuseal endpoint that receives Docuseal form.completed
events, looks up the signer by email in EspoCRM, and updates the
cMemberAgreementSignedAt field on the contact.
NOTE: The CRM field name cMemberAgreementSignedAt is a placeholder
following the existing c-prefix convention. The client should confirm
the actual field name and adjust in docuseal_processor.py if needed.
@coderabbitai

coderabbitaiBot commented Feb 27, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Introduces a complete Docuseal webhook integration system. An API endpoint receives and validates webhook payloads, enqueues background jobs for form completion events, and processes them asynchronously to update CRM contact records with signed agreement timestamps.

Changes

Cohort / File(s)Summary
Webhook API Endpoint
apps/worker/src/five08/backend/api.py
New endpoint handler docuseal_webhook_handler that authenticates requests, validates JSON payloads against DocusealWebhookPayload, filters for "form.completed" events, and enqueues background jobs with idempotency keys. Returns 202 on success, 400/401/503 on errors.
Job System Integration
apps/worker/src/five08/worker/actors.py, apps/worker/src/five08/worker/jobs.py
Registers process_docuseal_agreement_job in the handler dispatch map and implements the job function that instantiates DocusealAgreementProcessor to process agreement data.
CRM Processor
apps/worker/src/five08/worker/crm/docuseal_processor.py
New DocusealAgreementProcessor class that queries EspoAPI to find contacts by email and updates their cMemberAgreementSignedAt field with completion timestamps. Includes error handling for CRM search/update failures.
Data Models
apps/worker/src/five08/worker/models.py
Adds DocusealSubmitter and DocusealWebhookPayload Pydantic models to validate incoming webhook data with fields for event type, timestamp, submitter identity, and completion details.
Tests
tests/unit/test_backend_api.py, tests/unit/test_worker_models.py
Comprehensive test coverage for webhook authorization, payload validation, job enqueueing with idempotency verification, event filtering, CRM failures, and model parsing.

Sequence Diagram

sequenceDiagram
actor Client as Docuseal<br/>(Client)
participant API as API<br/>Endpoint
participant Queue as Job<br/>Queue
participant Worker as Background<br/>Worker
participant CRM as EspoAPI<br/>(CRM)
Client->>API: POST /webhooks/docuseal<br/>(form.completed event)
API->>API: Validate auth & payload
API->>API: Filter event type
API->>Queue: enqueue_job<br/>(process_docuseal_agreement_job)
Queue-->>API: Job enqueued
API-->>Client: 202 Accepted
Worker->>Queue: Pick up job
Worker->>Worker: DocusealAgreementProcessor<br/>instantiate
Worker->>CRM: GET /Contact<br/>(search by email)
CRM-->>Worker: Contact found<br/>(id, details)
Worker->>CRM: PUT /Contact/{id}<br/>(set cMemberAgreementSignedAt)
CRM-->>Worker: Update successful
Worker->>Worker: Log completion<br/>success: true
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~35 minutes

Poem

🐰 A webhook hops into view,
With forms now complete and true,
Through queues and jobs, our CRM knows,
Agreements signed—the workflow flows! ✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe pull request title accurately and concisely describes the main change: adding a new Docuseal webhook endpoint for processing member agreement signatures.
Docstring Coverage✅ PassedDocstring coverage is 88.89% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
tests/unit/test_backend_api.py (1)

603-681: Add regression tests for blank/whitespace email and timestamp inputs.

Current tests don’t cover empty-string edge cases for data.email and completed_at/timestamp, which are critical for robust ingest validation.

🧪 Suggested additional tests
+def test_docuseal_webhook_rejects_blank_email(+ client: TestClient,+ auth_headers: dict[str, str],+) -> None:+ payload = {+ **_DOCUSEAL_PAYLOAD,+ "data": {**_DOCUSEAL_PAYLOAD["data"], "email": " "},+ }+ response = client.post("/webhooks/docuseal", json=payload, headers=auth_headers)+ assert response.status_code == 400+ assert response.json()["error"] == "invalid_payload"+++def test_docuseal_webhook_rejects_blank_completed_timestamp(+ client: TestClient,+ auth_headers: dict[str, str],+) -> None:+ payload = {+ **_DOCUSEAL_PAYLOAD,+ "timestamp": " ",+ "data": {**_DOCUSEAL_PAYLOAD["data"], "completed_at": " "},+ }+ response = client.post("/webhooks/docuseal", json=payload, headers=auth_headers)+ assert response.status_code == 400+ assert response.json()["error"] == "invalid_payload"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@tests/unit/test_backend_api.py` around lines 603 - 681, Add regression tests
to tests/unit/test_backend_api.py to cover blank/whitespace edge cases for the
Docuseal webhook: create two new tests (e.g.,
test_docuseal_webhook_rejects_blank_email and
test_docuseal_webhook_rejects_blank_timestamp) that POST to "/webhooks/docuseal"
using payloads based on _DOCUSEAL_PAYLOAD but with data.email set to "" and " " (whitespace) and completed_at/timestamp set to "" and " " respectively, and
assert the handler returns a 400 with error "invalid_payload" (mirror the style
of test_docuseal_webhook_rejects_invalid_payload). Ensure the tests use the
TestClient and auth_headers fixtures and maintain naming/structure consistent
with existing tests so they exercise the same validation logic in the webhook
handler.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/worker/src/five08/backend/api.py`:
- Around line 718-729: Normalize and validate the Docuseal fields before calling
enqueue_job: compute email = (submitter.email or "").strip() and completed_at =
submitter.completed_at or payload.timestamp (normalize/validate if it's a
string), then if email == "" or completed_at is falsy raise/return a 400 HTTP
error (e.g., HTTPException) and do not call enqueue_job; otherwise pass the
normalized email and completed_at into enqueue_job (keeping
idempotency_key=f"docuseal-agreement:{submitter.id}" and using
process_docuseal_agreement_job) so only non-blank values are enqueued.
In `@apps/worker/src/five08/worker/crm/docuseal_processor.py`:
- Around line 43-77: Replace raw email logging to avoid PII: update the logger
calls in docuseal_processor.py (the logger.error in the CRM search exception,
the logger.warning when no contact is found, the logger.error in the
EspoAPIError except block, and the final logger.info) to omit or mask the
variable email and instead log contact_id or submission_id (or a deterministic
masked/hash of email). Specifically, change references that pass email into
logger.* (e.g., the "CRM search failed for email=%s", "No CRM contact found for
email=%s", "CRM update failed for contact_id=%s: %s" and "Marked member
agreement signed contact_id=%s email=%s") so they only interpolate
contact_id/submission_id or a masked_email variable (created by hashing or
redacting) and ensure error payloads returned in dicts do not expose the raw
email either.
---
Nitpick comments:
In `@tests/unit/test_backend_api.py`:
- Around line 603-681: Add regression tests to tests/unit/test_backend_api.py to
cover blank/whitespace edge cases for the Docuseal webhook: create two new tests
(e.g., test_docuseal_webhook_rejects_blank_email and
test_docuseal_webhook_rejects_blank_timestamp) that POST to "/webhooks/docuseal"
using payloads based on _DOCUSEAL_PAYLOAD but with data.email set to "" and " " (whitespace) and completed_at/timestamp set to "" and " " respectively, and
assert the handler returns a 400 with error "invalid_payload" (mirror the style
of test_docuseal_webhook_rejects_invalid_payload). Ensure the tests use the
TestClient and auth_headers fixtures and maintain naming/structure consistent
with existing tests so they exercise the same validation logic in the webhook
handler.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9989f1d and 2269949.

📒 Files selected for processing (7)
  • apps/worker/src/five08/backend/api.py
  • apps/worker/src/five08/worker/actors.py
  • apps/worker/src/five08/worker/crm/docuseal_processor.py
  • apps/worker/src/five08/worker/jobs.py
  • apps/worker/src/five08/worker/models.py
  • tests/unit/test_backend_api.py
  • tests/unit/test_worker_models.py

Comment on lines +718 to +729
submitter = payload.data
completed_at = submitter.completed_at or payload.timestamp
queue = request.app.state.queue
try:
job: EnqueuedJob = await asyncio.to_thread(
enqueue_job,
queue=queue,
fn=process_docuseal_agreement_job,
args=(submitter.email, completed_at, submitter.id),
settings=settings,
idempotency_key=f"docuseal-agreement:{submitter.id}",
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Normalize and reject blank Docuseal identity/timestamp fields before enqueue.

submitter.email and completed_at/timestamp are used directly; whitespace/empty strings can still be queued. Add strip + non-empty validation before enqueue_job.

✅ Suggested validation hardening diff
 submitter = payload.data
- completed_at = submitter.completed_at or payload.timestamp+ email = submitter.email.strip().lower()+ if not email:+ return JSONResponse(+ {"error": "invalid_payload", "detail": "data.email is required"},+ status_code=400,+ )++ completed_at = (submitter.completed_at or payload.timestamp).strip()+ if not completed_at:+ return JSONResponse(+ {"error": "invalid_payload", "detail": "completed_at/timestamp is required"},+ status_code=400,+ )
queue = request.app.state.queue
try:
job: EnqueuedJob = await asyncio.to_thread(
enqueue_job,
queue=queue,
fn=process_docuseal_agreement_job,
- args=(submitter.email, completed_at, submitter.id),+ args=(email, completed_at, submitter.id),
settings=settings,
idempotency_key=f"docuseal-agreement:{submitter.id}",
)
As per coding guidelines "Worker ingest endpoints must validate input, persist jobs, enqueue, and return 202 quickly without performing long processing".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/worker/src/five08/backend/api.py` around lines 718 - 729, Normalize and
validate the Docuseal fields before calling enqueue_job: compute email =
(submitter.email or "").strip() and completed_at = submitter.completed_at or
payload.timestamp (normalize/validate if it's a string), then if email == "" or
completed_at is falsy raise/return a 400 HTTP error (e.g., HTTPException) and do
not call enqueue_job; otherwise pass the normalized email and completed_at into
enqueue_job (keeping idempotency_key=f"docuseal-agreement:{submitter.id}" and
using process_docuseal_agreement_job) so only non-blank values are enqueued.

Comment on lines +43 to +77
logger.error("CRM search failed for email=%s: %s", email, exc)
return {
"success": False,
"email": email,
"error": f"CRM search failed: {exc}",
}

contacts = result.get("list", [])
if not contacts:
logger.warning("No CRM contact found for email=%s", email)
return {"success": False, "email": email, "error": "contact_not_found"}

contact = contacts[0]
contact_id = contact["id"]

try:
self.api.request(
"PUT",
f"Contact/{contact_id}",
{"cMemberAgreementSignedAt": completed_at},
)
except EspoAPIError as exc:
logger.error("CRM update failed for contact_id=%s: %s", contact_id, exc)
return {
"success": False,
"email": email,
"contact_id": contact_id,
"error": f"CRM update failed: {exc}",
}

logger.info(
"Marked member agreement signed contact_id=%s email=%s",
contact_id,
email,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Redact signer email from logs to avoid PII leakage.

The new log lines emit raw email addresses. Please log submission_id/contact_id (or a masked hash) instead of full email.

🔒 Suggested log-hardening diff
- logger.error("CRM search failed for email=%s: %s", email, exc)+ logger.error("CRM search failed submission_id=%s: %s", submission_id, exc)
...
- logger.warning("No CRM contact found for email=%s", email)+ logger.warning("No CRM contact found submission_id=%s", submission_id)
...
- "Marked member agreement signed contact_id=%s email=%s",+ "Marked member agreement signed contact_id=%s submission_id=%s",
contact_id,
- email,+ submission_id,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/worker/src/five08/worker/crm/docuseal_processor.py` around lines 43 -
77, Replace raw email logging to avoid PII: update the logger calls in
docuseal_processor.py (the logger.error in the CRM search exception, the
logger.warning when no contact is found, the logger.error in the EspoAPIError
except block, and the final logger.info) to omit or mask the variable email and
instead log contact_id or submission_id (or a deterministic masked/hash of
email). Specifically, change references that pass email into logger.* (e.g., the
"CRM search failed for email=%s", "No CRM contact found for email=%s", "CRM
update failed for contact_id=%s: %s" and "Marked member agreement signed
contact_id=%s email=%s") so they only interpolate contact_id/submission_id or a
masked_email variable (created by hashing or redacting) and ensure error
payloads returned in dicts do not expose the raw email either.

@michaelmwu

Copy link
Copy Markdown
Member

Great, thanks for this, I'll take it from here.

We should be filtering on the template being the member agreement.

The documentation of what a webhook looks like is:

{
"event_type": "form.completed",
"timestamp": "2026-02-28T00:18:34Z",
"data": {
"id": 123,
"submission_id": 123,
"email": "__@508.dev",
"phone": null,
"name": null,
"ua": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36",
"ip": "196.249.122.246",
"sent_at": "2026-02-27T19:55:47.564Z",
"opened_at": "2026-02-27T20:00:04.861Z",
"completed_at": "2026-02-27T20:01:07.888Z",
"declined_at": null,
"created_at": "2026-02-27T19:53:56.461Z",
"updated_at": "2026-02-27T20:01:07.892Z",
"external_id": null,
"metadata": {},
"status": "completed",
"application_key": null,
"decline_reason": null,
"role": "Tachera Sasi",
"preferences": {
"send_email": true
},
"values": [
{
"field": "Date Field 3",
"value": "2026-02-27"
},
{
"field": "Signature Field 3",
"value": "https://docuseal.508.dev/file/___.png"
}
],
"documents": [
{
"name": "508.dev Member Agreement",
"url": "https://docuseal.508.dev/file/_____.dif"
}
],
"audit_log_url": "https://docuseal.508.dev/file/____.pdf",
"submission_url": "https://docuseal.508.dev/e/iKjYF8WWD5kWxC",
"template": {
"id": 69,
"name": "508.dev Member Agreement",
"external_id": null,
"created_at": "2026-02-27T19:47:21.222Z",
"updated_at": "2026-02-27T20:49:42.420Z",
"folder_name": "Signed Agreements / External Contracts"
},
"submission": {
"id": 416,
"audit_log_url": "https://docuseal.508.dev/file/_____.pdf",
"combined_document_url": null,
"status": "completed",
"url": "https://docuseal.508.dev/e/iKjYF8WWD5kWxC",
"variables": {},
"created_at": "2026-02-27T19:53:56.452Z"
}
}
}

@michaelmwu

Copy link
Copy Markdown
Member

#70

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@happysmile001@michaelmwu@kanbei65
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: add Docuseal webhook endpoint for member agreement (#48) by happysmile001 · Pull Request #67 · 508-dev/508-workflows · GitHub
Skip to content

feat: add Docuseal webhook endpoint for member agreement (#48) - #67

Closed
happysmile001 wants to merge 1 commit into
508-dev:mainfrom
happysmile001:feat/docuseal-webhook
Closed

feat: add Docuseal webhook endpoint for member agreement (#48)#67
happysmile001 wants to merge 1 commit into
508-dev:mainfrom
happysmile001:feat/docuseal-webhook

Conversation

@happysmile001

@happysmile001happysmile001 commented Feb 27, 2026

Copy link
Copy Markdown

Add /webhooks/docuseal endpoint that receives Docuseal form.completed events, looks up the signer by email in EspoCRM, and updates the cMemberAgreementSignedAt field on the contact.

NOTE: The CRM field name cMemberAgreementSignedAt is a placeholder following the existing c-prefix convention. The client should confirm the actual field name and adjust in docuseal_processor.py if needed.

Summary by CodeRabbit

  • New Features

    • Added Docuseal webhook integration that automatically processes completed form submissions.
    • System now automatically updates customer records in the CRM to mark member agreements as signed upon form completion.
  • Tests

    • Added comprehensive test coverage for webhook authorization, payload validation, and job handling.

Add /webhooks/docuseal endpoint that receives Docuseal form.completed
events, looks up the signer by email in EspoCRM, and updates the
cMemberAgreementSignedAt field on the contact.
NOTE: The CRM field name cMemberAgreementSignedAt is a placeholder
following the existing c-prefix convention. The client should confirm
the actual field name and adjust in docuseal_processor.py if needed.
@coderabbitai

coderabbitaiBot commented Feb 27, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Introduces a complete Docuseal webhook integration system. An API endpoint receives and validates webhook payloads, enqueues background jobs for form completion events, and processes them asynchronously to update CRM contact records with signed agreement timestamps.

Changes

Cohort / File(s)Summary
Webhook API Endpoint
apps/worker/src/five08/backend/api.py
New endpoint handler docuseal_webhook_handler that authenticates requests, validates JSON payloads against DocusealWebhookPayload, filters for "form.completed" events, and enqueues background jobs with idempotency keys. Returns 202 on success, 400/401/503 on errors.
Job System Integration
apps/worker/src/five08/worker/actors.py, apps/worker/src/five08/worker/jobs.py
Registers process_docuseal_agreement_job in the handler dispatch map and implements the job function that instantiates DocusealAgreementProcessor to process agreement data.
CRM Processor
apps/worker/src/five08/worker/crm/docuseal_processor.py
New DocusealAgreementProcessor class that queries EspoAPI to find contacts by email and updates their cMemberAgreementSignedAt field with completion timestamps. Includes error handling for CRM search/update failures.
Data Models
apps/worker/src/five08/worker/models.py
Adds DocusealSubmitter and DocusealWebhookPayload Pydantic models to validate incoming webhook data with fields for event type, timestamp, submitter identity, and completion details.
Tests
tests/unit/test_backend_api.py, tests/unit/test_worker_models.py
Comprehensive test coverage for webhook authorization, payload validation, job enqueueing with idempotency verification, event filtering, CRM failures, and model parsing.

Sequence Diagram

sequenceDiagram
actor Client as Docuseal<br/>(Client)
participant API as API<br/>Endpoint
participant Queue as Job<br/>Queue
participant Worker as Background<br/>Worker
participant CRM as EspoAPI<br/>(CRM)
Client->>API: POST /webhooks/docuseal<br/>(form.completed event)
API->>API: Validate auth & payload
API->>API: Filter event type
API->>Queue: enqueue_job<br/>(process_docuseal_agreement_job)
Queue-->>API: Job enqueued
API-->>Client: 202 Accepted
Worker->>Queue: Pick up job
Worker->>Worker: DocusealAgreementProcessor<br/>instantiate
Worker->>CRM: GET /Contact<br/>(search by email)
CRM-->>Worker: Contact found<br/>(id, details)
Worker->>CRM: PUT /Contact/{id}<br/>(set cMemberAgreementSignedAt)
CRM-->>Worker: Update successful
Worker->>Worker: Log completion<br/>success: true
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~35 minutes

Poem

🐰 A webhook hops into view,
With forms now complete and true,
Through queues and jobs, our CRM knows,
Agreements signed—the workflow flows! ✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe pull request title accurately and concisely describes the main change: adding a new Docuseal webhook endpoint for processing member agreement signatures.
Docstring Coverage✅ PassedDocstring coverage is 88.89% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
tests/unit/test_backend_api.py (1)

603-681: Add regression tests for blank/whitespace email and timestamp inputs.

Current tests don’t cover empty-string edge cases for data.email and completed_at/timestamp, which are critical for robust ingest validation.

🧪 Suggested additional tests
+def test_docuseal_webhook_rejects_blank_email(+ client: TestClient,+ auth_headers: dict[str, str],+) -> None:+ payload = {+ **_DOCUSEAL_PAYLOAD,+ "data": {**_DOCUSEAL_PAYLOAD["data"], "email": " "},+ }+ response = client.post("/webhooks/docuseal", json=payload, headers=auth_headers)+ assert response.status_code == 400+ assert response.json()["error"] == "invalid_payload"+++def test_docuseal_webhook_rejects_blank_completed_timestamp(+ client: TestClient,+ auth_headers: dict[str, str],+) -> None:+ payload = {+ **_DOCUSEAL_PAYLOAD,+ "timestamp": " ",+ "data": {**_DOCUSEAL_PAYLOAD["data"], "completed_at": " "},+ }+ response = client.post("/webhooks/docuseal", json=payload, headers=auth_headers)+ assert response.status_code == 400+ assert response.json()["error"] == "invalid_payload"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@tests/unit/test_backend_api.py` around lines 603 - 681, Add regression tests
to tests/unit/test_backend_api.py to cover blank/whitespace edge cases for the
Docuseal webhook: create two new tests (e.g.,
test_docuseal_webhook_rejects_blank_email and
test_docuseal_webhook_rejects_blank_timestamp) that POST to "/webhooks/docuseal"
using payloads based on _DOCUSEAL_PAYLOAD but with data.email set to "" and " " (whitespace) and completed_at/timestamp set to "" and " " respectively, and
assert the handler returns a 400 with error "invalid_payload" (mirror the style
of test_docuseal_webhook_rejects_invalid_payload). Ensure the tests use the
TestClient and auth_headers fixtures and maintain naming/structure consistent
with existing tests so they exercise the same validation logic in the webhook
handler.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/worker/src/five08/backend/api.py`:
- Around line 718-729: Normalize and validate the Docuseal fields before calling
enqueue_job: compute email = (submitter.email or "").strip() and completed_at =
submitter.completed_at or payload.timestamp (normalize/validate if it's a
string), then if email == "" or completed_at is falsy raise/return a 400 HTTP
error (e.g., HTTPException) and do not call enqueue_job; otherwise pass the
normalized email and completed_at into enqueue_job (keeping
idempotency_key=f"docuseal-agreement:{submitter.id}" and using
process_docuseal_agreement_job) so only non-blank values are enqueued.
In `@apps/worker/src/five08/worker/crm/docuseal_processor.py`:
- Around line 43-77: Replace raw email logging to avoid PII: update the logger
calls in docuseal_processor.py (the logger.error in the CRM search exception,
the logger.warning when no contact is found, the logger.error in the
EspoAPIError except block, and the final logger.info) to omit or mask the
variable email and instead log contact_id or submission_id (or a deterministic
masked/hash of email). Specifically, change references that pass email into
logger.* (e.g., the "CRM search failed for email=%s", "No CRM contact found for
email=%s", "CRM update failed for contact_id=%s: %s" and "Marked member
agreement signed contact_id=%s email=%s") so they only interpolate
contact_id/submission_id or a masked_email variable (created by hashing or
redacting) and ensure error payloads returned in dicts do not expose the raw
email either.
---
Nitpick comments:
In `@tests/unit/test_backend_api.py`:
- Around line 603-681: Add regression tests to tests/unit/test_backend_api.py to
cover blank/whitespace edge cases for the Docuseal webhook: create two new tests
(e.g., test_docuseal_webhook_rejects_blank_email and
test_docuseal_webhook_rejects_blank_timestamp) that POST to "/webhooks/docuseal"
using payloads based on _DOCUSEAL_PAYLOAD but with data.email set to "" and " " (whitespace) and completed_at/timestamp set to "" and " " respectively, and
assert the handler returns a 400 with error "invalid_payload" (mirror the style
of test_docuseal_webhook_rejects_invalid_payload). Ensure the tests use the
TestClient and auth_headers fixtures and maintain naming/structure consistent
with existing tests so they exercise the same validation logic in the webhook
handler.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9989f1d and 2269949.

📒 Files selected for processing (7)
  • apps/worker/src/five08/backend/api.py
  • apps/worker/src/five08/worker/actors.py
  • apps/worker/src/five08/worker/crm/docuseal_processor.py
  • apps/worker/src/five08/worker/jobs.py
  • apps/worker/src/five08/worker/models.py
  • tests/unit/test_backend_api.py
  • tests/unit/test_worker_models.py

Comment on lines +718 to +729
submitter = payload.data
completed_at = submitter.completed_at or payload.timestamp
queue = request.app.state.queue
try:
job: EnqueuedJob = await asyncio.to_thread(
enqueue_job,
queue=queue,
fn=process_docuseal_agreement_job,
args=(submitter.email, completed_at, submitter.id),
settings=settings,
idempotency_key=f"docuseal-agreement:{submitter.id}",
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Normalize and reject blank Docuseal identity/timestamp fields before enqueue.

submitter.email and completed_at/timestamp are used directly; whitespace/empty strings can still be queued. Add strip + non-empty validation before enqueue_job.

✅ Suggested validation hardening diff
 submitter = payload.data
- completed_at = submitter.completed_at or payload.timestamp+ email = submitter.email.strip().lower()+ if not email:+ return JSONResponse(+ {"error": "invalid_payload", "detail": "data.email is required"},+ status_code=400,+ )++ completed_at = (submitter.completed_at or payload.timestamp).strip()+ if not completed_at:+ return JSONResponse(+ {"error": "invalid_payload", "detail": "completed_at/timestamp is required"},+ status_code=400,+ )
queue = request.app.state.queue
try:
job: EnqueuedJob = await asyncio.to_thread(
enqueue_job,
queue=queue,
fn=process_docuseal_agreement_job,
- args=(submitter.email, completed_at, submitter.id),+ args=(email, completed_at, submitter.id),
settings=settings,
idempotency_key=f"docuseal-agreement:{submitter.id}",
)
As per coding guidelines "Worker ingest endpoints must validate input, persist jobs, enqueue, and return 202 quickly without performing long processing".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/worker/src/five08/backend/api.py` around lines 718 - 729, Normalize and
validate the Docuseal fields before calling enqueue_job: compute email =
(submitter.email or "").strip() and completed_at = submitter.completed_at or
payload.timestamp (normalize/validate if it's a string), then if email == "" or
completed_at is falsy raise/return a 400 HTTP error (e.g., HTTPException) and do
not call enqueue_job; otherwise pass the normalized email and completed_at into
enqueue_job (keeping idempotency_key=f"docuseal-agreement:{submitter.id}" and
using process_docuseal_agreement_job) so only non-blank values are enqueued.

Comment on lines +43 to +77
logger.error("CRM search failed for email=%s: %s", email, exc)
return {
"success": False,
"email": email,
"error": f"CRM search failed: {exc}",
}

contacts = result.get("list", [])
if not contacts:
logger.warning("No CRM contact found for email=%s", email)
return {"success": False, "email": email, "error": "contact_not_found"}

contact = contacts[0]
contact_id = contact["id"]

try:
self.api.request(
"PUT",
f"Contact/{contact_id}",
{"cMemberAgreementSignedAt": completed_at},
)
except EspoAPIError as exc:
logger.error("CRM update failed for contact_id=%s: %s", contact_id, exc)
return {
"success": False,
"email": email,
"contact_id": contact_id,
"error": f"CRM update failed: {exc}",
}

logger.info(
"Marked member agreement signed contact_id=%s email=%s",
contact_id,
email,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Redact signer email from logs to avoid PII leakage.

The new log lines emit raw email addresses. Please log submission_id/contact_id (or a masked hash) instead of full email.

🔒 Suggested log-hardening diff
- logger.error("CRM search failed for email=%s: %s", email, exc)+ logger.error("CRM search failed submission_id=%s: %s", submission_id, exc)
...
- logger.warning("No CRM contact found for email=%s", email)+ logger.warning("No CRM contact found submission_id=%s", submission_id)
...
- "Marked member agreement signed contact_id=%s email=%s",+ "Marked member agreement signed contact_id=%s submission_id=%s",
contact_id,
- email,+ submission_id,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/worker/src/five08/worker/crm/docuseal_processor.py` around lines 43 -
77, Replace raw email logging to avoid PII: update the logger calls in
docuseal_processor.py (the logger.error in the CRM search exception, the
logger.warning when no contact is found, the logger.error in the EspoAPIError
except block, and the final logger.info) to omit or mask the variable email and
instead log contact_id or submission_id (or a deterministic masked/hash of
email). Specifically, change references that pass email into logger.* (e.g., the
"CRM search failed for email=%s", "No CRM contact found for email=%s", "CRM
update failed for contact_id=%s: %s" and "Marked member agreement signed
contact_id=%s email=%s") so they only interpolate contact_id/submission_id or a
masked_email variable (created by hashing or redacting) and ensure error
payloads returned in dicts do not expose the raw email either.

@michaelmwu

Copy link
Copy Markdown
Member

Great, thanks for this, I'll take it from here.

We should be filtering on the template being the member agreement.

The documentation of what a webhook looks like is:

{
"event_type": "form.completed",
"timestamp": "2026-02-28T00:18:34Z",
"data": {
"id": 123,
"submission_id": 123,
"email": "__@508.dev",
"phone": null,
"name": null,
"ua": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36",
"ip": "196.249.122.246",
"sent_at": "2026-02-27T19:55:47.564Z",
"opened_at": "2026-02-27T20:00:04.861Z",
"completed_at": "2026-02-27T20:01:07.888Z",
"declined_at": null,
"created_at": "2026-02-27T19:53:56.461Z",
"updated_at": "2026-02-27T20:01:07.892Z",
"external_id": null,
"metadata": {},
"status": "completed",
"application_key": null,
"decline_reason": null,
"role": "Tachera Sasi",
"preferences": {
"send_email": true
},
"values": [
{
"field": "Date Field 3",
"value": "2026-02-27"
},
{
"field": "Signature Field 3",
"value": "https://docuseal.508.dev/file/___.png"
}
],
"documents": [
{
"name": "508.dev Member Agreement",
"url": "https://docuseal.508.dev/file/_____.dif"
}
],
"audit_log_url": "https://docuseal.508.dev/file/____.pdf",
"submission_url": "https://docuseal.508.dev/e/iKjYF8WWD5kWxC",
"template": {
"id": 69,
"name": "508.dev Member Agreement",
"external_id": null,
"created_at": "2026-02-27T19:47:21.222Z",
"updated_at": "2026-02-27T20:49:42.420Z",
"folder_name": "Signed Agreements / External Contracts"
},
"submission": {
"id": 416,
"audit_log_url": "https://docuseal.508.dev/file/_____.pdf",
"combined_document_url": null,
"status": "completed",
"url": "https://docuseal.508.dev/e/iKjYF8WWD5kWxC",
"variables": {},
"created_at": "2026-02-27T19:53:56.452Z"
}
}
}

@michaelmwu

Copy link
Copy Markdown
Member

#70

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@happysmile001@michaelmwu@kanbei65
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat: add Docuseal webhook endpoint for member agreement (#48) by happysmile001 · Pull Request #67 · 508-dev/508-workflows · GitHub
Skip to content

feat: add Docuseal webhook endpoint for member agreement (#48) - #67

Closed
happysmile001 wants to merge 1 commit into
508-dev:mainfrom
happysmile001:feat/docuseal-webhook
Closed

feat: add Docuseal webhook endpoint for member agreement (#48)#67
happysmile001 wants to merge 1 commit into
508-dev:mainfrom
happysmile001:feat/docuseal-webhook

Conversation

@happysmile001

@happysmile001happysmile001 commented Feb 27, 2026

Copy link
Copy Markdown

Add /webhooks/docuseal endpoint that receives Docuseal form.completed events, looks up the signer by email in EspoCRM, and updates the cMemberAgreementSignedAt field on the contact.

NOTE: The CRM field name cMemberAgreementSignedAt is a placeholder following the existing c-prefix convention. The client should confirm the actual field name and adjust in docuseal_processor.py if needed.

Summary by CodeRabbit

  • New Features

    • Added Docuseal webhook integration that automatically processes completed form submissions.
    • System now automatically updates customer records in the CRM to mark member agreements as signed upon form completion.
  • Tests

    • Added comprehensive test coverage for webhook authorization, payload validation, and job handling.

Add /webhooks/docuseal endpoint that receives Docuseal form.completed
events, looks up the signer by email in EspoCRM, and updates the
cMemberAgreementSignedAt field on the contact.
NOTE: The CRM field name cMemberAgreementSignedAt is a placeholder
following the existing c-prefix convention. The client should confirm
the actual field name and adjust in docuseal_processor.py if needed.
@coderabbitai

coderabbitaiBot commented Feb 27, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Introduces a complete Docuseal webhook integration system. An API endpoint receives and validates webhook payloads, enqueues background jobs for form completion events, and processes them asynchronously to update CRM contact records with signed agreement timestamps.

Changes

Cohort / File(s)Summary
Webhook API Endpoint
apps/worker/src/five08/backend/api.py
New endpoint handler docuseal_webhook_handler that authenticates requests, validates JSON payloads against DocusealWebhookPayload, filters for "form.completed" events, and enqueues background jobs with idempotency keys. Returns 202 on success, 400/401/503 on errors.
Job System Integration
apps/worker/src/five08/worker/actors.py, apps/worker/src/five08/worker/jobs.py
Registers process_docuseal_agreement_job in the handler dispatch map and implements the job function that instantiates DocusealAgreementProcessor to process agreement data.
CRM Processor
apps/worker/src/five08/worker/crm/docuseal_processor.py
New DocusealAgreementProcessor class that queries EspoAPI to find contacts by email and updates their cMemberAgreementSignedAt field with completion timestamps. Includes error handling for CRM search/update failures.
Data Models
apps/worker/src/five08/worker/models.py
Adds DocusealSubmitter and DocusealWebhookPayload Pydantic models to validate incoming webhook data with fields for event type, timestamp, submitter identity, and completion details.
Tests
tests/unit/test_backend_api.py, tests/unit/test_worker_models.py
Comprehensive test coverage for webhook authorization, payload validation, job enqueueing with idempotency verification, event filtering, CRM failures, and model parsing.

Sequence Diagram

sequenceDiagram
actor Client as Docuseal<br/>(Client)
participant API as API<br/>Endpoint
participant Queue as Job<br/>Queue
participant Worker as Background<br/>Worker
participant CRM as EspoAPI<br/>(CRM)
Client->>API: POST /webhooks/docuseal<br/>(form.completed event)
API->>API: Validate auth & payload
API->>API: Filter event type
API->>Queue: enqueue_job<br/>(process_docuseal_agreement_job)
Queue-->>API: Job enqueued
API-->>Client: 202 Accepted
Worker->>Queue: Pick up job
Worker->>Worker: DocusealAgreementProcessor<br/>instantiate
Worker->>CRM: GET /Contact<br/>(search by email)
CRM-->>Worker: Contact found<br/>(id, details)
Worker->>CRM: PUT /Contact/{id}<br/>(set cMemberAgreementSignedAt)
CRM-->>Worker: Update successful
Worker->>Worker: Log completion<br/>success: true
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~35 minutes

Poem

🐰 A webhook hops into view,
With forms now complete and true,
Through queues and jobs, our CRM knows,
Agreements signed—the workflow flows! ✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe pull request title accurately and concisely describes the main change: adding a new Docuseal webhook endpoint for processing member agreement signatures.
Docstring Coverage✅ PassedDocstring coverage is 88.89% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
tests/unit/test_backend_api.py (1)

603-681: Add regression tests for blank/whitespace email and timestamp inputs.

Current tests don’t cover empty-string edge cases for data.email and completed_at/timestamp, which are critical for robust ingest validation.

🧪 Suggested additional tests
+def test_docuseal_webhook_rejects_blank_email(+ client: TestClient,+ auth_headers: dict[str, str],+) -> None:+ payload = {+ **_DOCUSEAL_PAYLOAD,+ "data": {**_DOCUSEAL_PAYLOAD["data"], "email": " "},+ }+ response = client.post("/webhooks/docuseal", json=payload, headers=auth_headers)+ assert response.status_code == 400+ assert response.json()["error"] == "invalid_payload"+++def test_docuseal_webhook_rejects_blank_completed_timestamp(+ client: TestClient,+ auth_headers: dict[str, str],+) -> None:+ payload = {+ **_DOCUSEAL_PAYLOAD,+ "timestamp": " ",+ "data": {**_DOCUSEAL_PAYLOAD["data"], "completed_at": " "},+ }+ response = client.post("/webhooks/docuseal", json=payload, headers=auth_headers)+ assert response.status_code == 400+ assert response.json()["error"] == "invalid_payload"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@tests/unit/test_backend_api.py` around lines 603 - 681, Add regression tests
to tests/unit/test_backend_api.py to cover blank/whitespace edge cases for the
Docuseal webhook: create two new tests (e.g.,
test_docuseal_webhook_rejects_blank_email and
test_docuseal_webhook_rejects_blank_timestamp) that POST to "/webhooks/docuseal"
using payloads based on _DOCUSEAL_PAYLOAD but with data.email set to "" and " " (whitespace) and completed_at/timestamp set to "" and " " respectively, and
assert the handler returns a 400 with error "invalid_payload" (mirror the style
of test_docuseal_webhook_rejects_invalid_payload). Ensure the tests use the
TestClient and auth_headers fixtures and maintain naming/structure consistent
with existing tests so they exercise the same validation logic in the webhook
handler.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/worker/src/five08/backend/api.py`:
- Around line 718-729: Normalize and validate the Docuseal fields before calling
enqueue_job: compute email = (submitter.email or "").strip() and completed_at =
submitter.completed_at or payload.timestamp (normalize/validate if it's a
string), then if email == "" or completed_at is falsy raise/return a 400 HTTP
error (e.g., HTTPException) and do not call enqueue_job; otherwise pass the
normalized email and completed_at into enqueue_job (keeping
idempotency_key=f"docuseal-agreement:{submitter.id}" and using
process_docuseal_agreement_job) so only non-blank values are enqueued.
In `@apps/worker/src/five08/worker/crm/docuseal_processor.py`:
- Around line 43-77: Replace raw email logging to avoid PII: update the logger
calls in docuseal_processor.py (the logger.error in the CRM search exception,
the logger.warning when no contact is found, the logger.error in the
EspoAPIError except block, and the final logger.info) to omit or mask the
variable email and instead log contact_id or submission_id (or a deterministic
masked/hash of email). Specifically, change references that pass email into
logger.* (e.g., the "CRM search failed for email=%s", "No CRM contact found for
email=%s", "CRM update failed for contact_id=%s: %s" and "Marked member
agreement signed contact_id=%s email=%s") so they only interpolate
contact_id/submission_id or a masked_email variable (created by hashing or
redacting) and ensure error payloads returned in dicts do not expose the raw
email either.
---
Nitpick comments:
In `@tests/unit/test_backend_api.py`:
- Around line 603-681: Add regression tests to tests/unit/test_backend_api.py to
cover blank/whitespace edge cases for the Docuseal webhook: create two new tests
(e.g., test_docuseal_webhook_rejects_blank_email and
test_docuseal_webhook_rejects_blank_timestamp) that POST to "/webhooks/docuseal"
using payloads based on _DOCUSEAL_PAYLOAD but with data.email set to "" and " " (whitespace) and completed_at/timestamp set to "" and " " respectively, and
assert the handler returns a 400 with error "invalid_payload" (mirror the style
of test_docuseal_webhook_rejects_invalid_payload). Ensure the tests use the
TestClient and auth_headers fixtures and maintain naming/structure consistent
with existing tests so they exercise the same validation logic in the webhook
handler.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9989f1d and 2269949.

📒 Files selected for processing (7)
  • apps/worker/src/five08/backend/api.py
  • apps/worker/src/five08/worker/actors.py
  • apps/worker/src/five08/worker/crm/docuseal_processor.py
  • apps/worker/src/five08/worker/jobs.py
  • apps/worker/src/five08/worker/models.py
  • tests/unit/test_backend_api.py
  • tests/unit/test_worker_models.py

Comment on lines +718 to +729
submitter = payload.data
completed_at = submitter.completed_at or payload.timestamp
queue = request.app.state.queue
try:
job: EnqueuedJob = await asyncio.to_thread(
enqueue_job,
queue=queue,
fn=process_docuseal_agreement_job,
args=(submitter.email, completed_at, submitter.id),
settings=settings,
idempotency_key=f"docuseal-agreement:{submitter.id}",
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Normalize and reject blank Docuseal identity/timestamp fields before enqueue.

submitter.email and completed_at/timestamp are used directly; whitespace/empty strings can still be queued. Add strip + non-empty validation before enqueue_job.

✅ Suggested validation hardening diff
 submitter = payload.data
- completed_at = submitter.completed_at or payload.timestamp+ email = submitter.email.strip().lower()+ if not email:+ return JSONResponse(+ {"error": "invalid_payload", "detail": "data.email is required"},+ status_code=400,+ )++ completed_at = (submitter.completed_at or payload.timestamp).strip()+ if not completed_at:+ return JSONResponse(+ {"error": "invalid_payload", "detail": "completed_at/timestamp is required"},+ status_code=400,+ )
queue = request.app.state.queue
try:
job: EnqueuedJob = await asyncio.to_thread(
enqueue_job,
queue=queue,
fn=process_docuseal_agreement_job,
- args=(submitter.email, completed_at, submitter.id),+ args=(email, completed_at, submitter.id),
settings=settings,
idempotency_key=f"docuseal-agreement:{submitter.id}",
)
As per coding guidelines "Worker ingest endpoints must validate input, persist jobs, enqueue, and return 202 quickly without performing long processing".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/worker/src/five08/backend/api.py` around lines 718 - 729, Normalize and
validate the Docuseal fields before calling enqueue_job: compute email =
(submitter.email or "").strip() and completed_at = submitter.completed_at or
payload.timestamp (normalize/validate if it's a string), then if email == "" or
completed_at is falsy raise/return a 400 HTTP error (e.g., HTTPException) and do
not call enqueue_job; otherwise pass the normalized email and completed_at into
enqueue_job (keeping idempotency_key=f"docuseal-agreement:{submitter.id}" and
using process_docuseal_agreement_job) so only non-blank values are enqueued.

Comment on lines +43 to +77
logger.error("CRM search failed for email=%s: %s", email, exc)
return {
"success": False,
"email": email,
"error": f"CRM search failed: {exc}",
}

contacts = result.get("list", [])
if not contacts:
logger.warning("No CRM contact found for email=%s", email)
return {"success": False, "email": email, "error": "contact_not_found"}

contact = contacts[0]
contact_id = contact["id"]

try:
self.api.request(
"PUT",
f"Contact/{contact_id}",
{"cMemberAgreementSignedAt": completed_at},
)
except EspoAPIError as exc:
logger.error("CRM update failed for contact_id=%s: %s", contact_id, exc)
return {
"success": False,
"email": email,
"contact_id": contact_id,
"error": f"CRM update failed: {exc}",
}

logger.info(
"Marked member agreement signed contact_id=%s email=%s",
contact_id,
email,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Redact signer email from logs to avoid PII leakage.

The new log lines emit raw email addresses. Please log submission_id/contact_id (or a masked hash) instead of full email.

🔒 Suggested log-hardening diff
- logger.error("CRM search failed for email=%s: %s", email, exc)+ logger.error("CRM search failed submission_id=%s: %s", submission_id, exc)
...
- logger.warning("No CRM contact found for email=%s", email)+ logger.warning("No CRM contact found submission_id=%s", submission_id)
...
- "Marked member agreement signed contact_id=%s email=%s",+ "Marked member agreement signed contact_id=%s submission_id=%s",
contact_id,
- email,+ submission_id,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/worker/src/five08/worker/crm/docuseal_processor.py` around lines 43 -
77, Replace raw email logging to avoid PII: update the logger calls in
docuseal_processor.py (the logger.error in the CRM search exception, the
logger.warning when no contact is found, the logger.error in the EspoAPIError
except block, and the final logger.info) to omit or mask the variable email and
instead log contact_id or submission_id (or a deterministic masked/hash of
email). Specifically, change references that pass email into logger.* (e.g., the
"CRM search failed for email=%s", "No CRM contact found for email=%s", "CRM
update failed for contact_id=%s: %s" and "Marked member agreement signed
contact_id=%s email=%s") so they only interpolate contact_id/submission_id or a
masked_email variable (created by hashing or redacting) and ensure error
payloads returned in dicts do not expose the raw email either.

@michaelmwu

Copy link
Copy Markdown
Member

Great, thanks for this, I'll take it from here.

We should be filtering on the template being the member agreement.

The documentation of what a webhook looks like is:

{
"event_type": "form.completed",
"timestamp": "2026-02-28T00:18:34Z",
"data": {
"id": 123,
"submission_id": 123,
"email": "__@508.dev",
"phone": null,
"name": null,
"ua": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36",
"ip": "196.249.122.246",
"sent_at": "2026-02-27T19:55:47.564Z",
"opened_at": "2026-02-27T20:00:04.861Z",
"completed_at": "2026-02-27T20:01:07.888Z",
"declined_at": null,
"created_at": "2026-02-27T19:53:56.461Z",
"updated_at": "2026-02-27T20:01:07.892Z",
"external_id": null,
"metadata": {},
"status": "completed",
"application_key": null,
"decline_reason": null,
"role": "Tachera Sasi",
"preferences": {
"send_email": true
},
"values": [
{
"field": "Date Field 3",
"value": "2026-02-27"
},
{
"field": "Signature Field 3",
"value": "https://docuseal.508.dev/file/___.png"
}
],
"documents": [
{
"name": "508.dev Member Agreement",
"url": "https://docuseal.508.dev/file/_____.dif"
}
],
"audit_log_url": "https://docuseal.508.dev/file/____.pdf",
"submission_url": "https://docuseal.508.dev/e/iKjYF8WWD5kWxC",
"template": {
"id": 69,
"name": "508.dev Member Agreement",
"external_id": null,
"created_at": "2026-02-27T19:47:21.222Z",
"updated_at": "2026-02-27T20:49:42.420Z",
"folder_name": "Signed Agreements / External Contracts"
},
"submission": {
"id": 416,
"audit_log_url": "https://docuseal.508.dev/file/_____.pdf",
"combined_document_url": null,
"status": "completed",
"url": "https://docuseal.508.dev/e/iKjYF8WWD5kWxC",
"variables": {},
"created_at": "2026-02-27T19:53:56.452Z"
}
}
}

@michaelmwu

Copy link
Copy Markdown
Member

#70

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@happysmile001@michaelmwu@kanbei65