With this module, your CRM or call center manages your affiliates’ PQRS (complaints and requests: petitions, complaints, claims and suggestions) server-to-server, without logging in to the Compense dashboard: you submit cases on the affiliate’s behalf, list and filter them (including overdue ones), look up the details with their history, respond as the entity and change the status or priority. The rules are exactly the same as in the app and the dashboard: a PQRS-AAAA-NNNNNN filing number (radicado), a statutory deadline, a state machine and a notification to the affiliate when they get a response.

The examples use Caja Horizonte, a family compensation fund (caja de compensación) whose call center logs cases in its CRM with HORIZONTE-CRM-… references.

Before you start #

Scopes #

ScopeAllows
pqrs:readList, view details with history, and statistics
pqrs:submitSubmit PQRS on an affiliate’s behalf
pqrs:manageRespond and change status or priority as the entity

Request only what your integration needs: a call center that only submits cases doesn’t need pqrs:manage. pqrs:manage is a high-value scope (it’s equivalent to signing an official response) and it’s on the list of scopes that will require request signing once that protection is turned on for your API key.

PQRS handling is mandatory for supervised entities, so today it’s available to all of them. If at some point your entity didn’t have the module, the gateway would return 403 with «El módulo PQRS no está contratado para esta entidad.» (“The PQRS module is not enabled for this entity.”).

Identifiers #

Each PQRS has an id (UUID) and a human-readable filing number (PQRS-2026-000123). Every route with {pqrs_id} accepts either one. Store both in your CRM alongside your own reference.

OperationRouteScope
SubmitPOST/v1/gateway/pqrs/pqrs:submit
ListGET/v1/gateway/pqrs/pqrs:read
StatisticsGET/v1/gateway/pqrs/stats/pqrs:read
DetailsGET/v1/gateway/pqrs/{pqrs_id}/pqrs:read
RespondPOST/v1/gateway/pqrs/{pqrs_id}/respond/pqrs:manage
Change status / priorityPOST/v1/gateway/pqrs/{pqrs_id}/status/pqrs:manage

Writes return the gateway’s standard envelope; data contains the full PQRS, including its history. Always send an Idempotency-Key: if you retry with the same key, the gateway returns the original result (idempotent_replay: true) instead of submitting or responding twice.

Statuses and statutory deadlines #

stateDiagram-v2
    [*] --> opened: submit
    opened --> in_progress
    opened --> escalated
    in_progress --> escalated
    escalated --> in_progress
    opened --> resolved: final response
    in_progress --> resolved: final response
    escalated --> resolved: final response
    opened --> closed
    in_progress --> closed
    escalated --> closed
    resolved --> closed
    closed --> [*]
  • opened, in_progress and escalated accept responses. A response with is_final: true moves the PQRS to resolved.
  • resolved can only move to closed; closed is terminal.
  • Setting the current status again is a no-op: it creates no history entry and no webhook.
  • Priorities: low, normal (default), high, urgent.

The deadline is calculated at submission: 15 business days under Law 1755 of 2015, excluding Saturdays, Sundays and public holidays. Each PQRS includes deadline (the due date) and is_overdue, which becomes true once the deadline passes without the case being resolved. Use them for your internal alerts; the overdue=true filter gives you the overdue cases directly.

Submitting on the affiliate’s behalf #

Your agent takes the call, identifies the affiliate by their ID number and submits the case. The affiliate must exist in your entity’s synced database (otherwise, 422).

import os
from compense_gateway import CompenseClient

client = CompenseClient(api_key=os.environ["COMPENSE_API_KEY"], environment="sandbox")

tx = client.pqrs.submit(
    document="1000000001",
    issue_type="claim",
    service_area="wallet",
    subject="Unrecognized charge",
    description="The affiliate reports a charge of 45.000 in the wallet that they don't recognize.",
    electronic_notification_consented=True,       # what the affiliate answered
    origin_channel="call_center",
    external_reference="HORIZONTE-CRM-88231",
    agent="ana.p",
    idempotency_key="horizonte-crm-88231-radicar",
)
pqrs = tx.data
print(pqrs["radicado"], pqrs["id"], pqrs["status"], pqrs["deadline"])
FieldRequiredValues
documentyesThe affiliate’s ID number (must exist in your synced database)
issue_typeyespetition · complaint · claim · suggestion
service_areayessubsidies · credits · health · marketplace · benefits · wallet · payments · general
subjectyesUp to 255 characters
descriptionyesFree text
electronic_notification_consentedyestrue / false
origin_channelno (default api)call_center · crm · phone · email · in_person · chat · whatsapp · web · api · other
external_referencenoThe case id in your CRM (up to 100). Used for filtering and included in webhooks
agentnoYour agent’s reference, for traceability (don’t use email addresses)
related_type + related_*_idnoLink to an application or product of the same affiliate

Consent to electronic notification. Electronic notification requires the affiliate’s express consent (Law 1437 of 2011, art. 56), so through the API it’s not assumed: the field is required and must reflect what the affiliate answered on the call: if they didn’t accept, send false (don’t omit it or assume true).

Origin channel. app is reserved for the affiliate app. Choose the actual channel the case came in through: it’s what the back office will see and what feeds the per-channel statistics.

Linking. To tie the case to a product, send related_type (credit_application, subsidy_application, credit or subsidy) and the matching id (related_credit_application_id, related_subsidy_application_id, related_credit_id or related_subsidy_active_id). In the SDKs these go as additional fields: extra in Python, JavaScript, PHP and .NET; Extra in Go. An id that doesn’t belong to the same affiliate returns 422.

Response (data, abridged):

JSON
{
  "id": "4f0c…",
  "radicado": "PQRS-2026-000123",
  "issue_type": "claim", "service_area": "wallet",
  "subject": "Unrecognized charge",
  "status": "opened", "priority": "normal",
  "origin_channel": "call_center", "external_reference": "HORIZONTE-CRM-88231",
  "electronic_notification_consented": true,
  "deadline": "2026-11-24T14:03:11-05:00", "is_overdue": false,
  "affiliate": {"document_number": "1000000001", "name": "S*** E***"},
  "description": "…", "responses": [], "attachments": [],
  "history": [
    {"from_status": null, "to_status": "opened", "at": "…", "source": "gateway",
     "actor": "integration:<your API key id> agent:ana.p", "note": "Radicación"}
  ]
}

The affiliate’s name is masked and their email address is never returned.

Listing and filtering #

GET/v1/gateway/pqrs/ paginates by page number (page, page_size up to 100) and returns a Page with count and results, from newest to oldest. Results don’t include description or history: that’s what the details endpoint is for.

Filters, all optional: status, issue_type, service_area, origin_channel, document, external_reference, date_from and date_to (AAAA-MM-DD, inclusive, on the submission date) and overdue=true for overdue cases (deadline passed and unresolved). A document that doesn’t exist returns an empty list.

vencidas = client.pqrs.list(status="opened", overdue=True, page_size=50)
print(vencidas.count)
for p in vencidas.results:
    print(p["radicado"], p["service_area"], p["deadline"], p["external_reference"])

# Find the case by your CRM reference
caso = client.pqrs.list(external_reference="HORIZONTE-CRM-88231").results
JSON
{"count": 37, "page": 1, "page_size": 50, "results": [ { "id": "…", "radicado": "PQRS-2026-000123", "status": "opened", "…": "…" } ]}

Statistics #

GET/v1/gateway/pqrs/stats/ summarizes by status, type and area, and counts overdue cases. It accepts issue_type, service_area, date_from and date_to.

stats = client.pqrs.stats(date_from="2026-10-01", date_to="2026-10-31")
print(stats["total"], stats["overdue"], stats["by_status"], stats["by_service_area"])
JSON
{"total": 37, "overdue": 2,
 "by_status": {"opened": 20, "in_progress": 9, "resolved": 8},
 "by_service_area": {"wallet": 15, "payments": 12, "general": 10},
 "by_type": {"claim": 25, "petition": 12}}

Details with history #

The details include description, responses (text, whether it’s final, date and responder), attachments and history: every status change with from_status, to_status, at, source (app, panel or gateway), actor and note. The actor never contains staff names or email addresses: staff for the dashboard, affiliate for the app and integration:<key> agent:<ref> for the API.

detalle = client.pqrs.get("PQRS-2026-000123")      # or the id (UUID)
for h in detalle["history"]:
    print(h["at"], h["from_status"], "→", h["to_status"], h["source"], h["actor"])

Responding as the entity #

You can only respond while the PQRS is opened, in_progress or escalated; otherwise, 409 («No se puede responder una solicitud resuelta.» — “A resolved request can’t be responded to.”). With is_final: true the PQRS moves to resolved and resolved_at is recorded. The affiliate gets a push notification with the response.

tx = client.pqrs.respond(
    "PQRS-2026-000123",
    response_text="The charge was reversed on October 28. You'll see the refund in your wallet.",
    is_final=True,
    agent="ana.p",
    idempotency_key="horizonte-crm-88231-resp-1",
)
print(tx.data["status"])   # resolved

A partial response (is_final: false or omitted) is added to responses without changing the status: use it to report progress without closing the case.

Changing status and priority #

When the case moves to another area or needs to be escalated, change the status and, if needed, the priority. The note is recorded in the history.

tx = client.pqrs.change_status(
    "PQRS-2026-000123",
    status="escalated",
    priority="high",
    note="Transferred to the payments area",
    agent="luis.r",
    idempotency_key="horizonte-crm-88231-estado-1",
)

An invalid transition returns 409 («No se puede pasar de “cerrada” a “abierta”.» — “Can’t move from ‘closed’ to ‘open’.”).

Common errors #

HTTPEngine codeWhendetail (example)
401—Missing X-API-Key—
403—API key without the scope, or module not enabledEl módulo PQRS no está contratado para esta entidad.
404PQRS_NOT_FOUNDPQRS doesn’t exist or belongs to another entityPQRS no encontrada.
409INVALID_STATEResponding to a resolved or closed PQRSNo se puede responder una solicitud cerrada.
409INVALID_TRANSITIONStatus transition not allowedNo se puede pasar de “cerrada” a “abierta”.
409—The same Idempotency-Key is still in progressA request with this Idempotency-Key is already being processed.
422VALIDATION_ERRORMissing fields or values outside the catalogDatos inválidos — subject: Este campo es requerido.
422AFFILIATE_NOT_FOUNDThe affiliate doesn’t exist in your entityNo existe un afiliado con ese documento en la entidad. Sincronícelo primero.
422—related_* belonging to another affiliate—
429—Per-minute limit or monthly quotasee Retry-After
502 / 504—The engine failed or didn’t respondRetry with the same key and quote the trace_id

detail carries the engine’s message in Spanish. To tell a business 409 apart from an idempotency conflict, look at the problem’s type (…/upstream-error versus …/idempotency-conflict); the full pattern is in Errors.

Events #

pqrs.* events are emitted in the same transaction as the change and from any channel (app, dashboard or API): that way your CRM learns about the PQRS the affiliate submits in the app and the responses your back office gives, without polling.

PQRS #

EventSinceWhen it's emitted
pqrs.created2026-10-27A PQRS was filed.
pqrs.status_changed2026-10-27A PQRS changed status (including moving to resolved through a final response).
pqrs.responded2026-10-27The entity recorded an official response.
JSON
{
  "event": "pqrs.responded",
  "version": "1",
  "tenant_id": "horizonte",
  "occurred_at": "2026-10-29T16:20:00Z",
  "resource": {
    "pqrs_id": "4f0c…", "radicado": "PQRS-2026-000123",
    "response_id": "9a1e…", "is_final": true, "status": "resolved",
    "external_reference": "HORIZONTE-CRM-88231",
    "source": "gateway", "actor": "integration:<key> agent:ana.p"
  }
}
  • pqrs.created also includes issue_type, service_area, status, priority, origin_channel, affiliate_id and deadline; pqrs.status_changed includes from_status, to_status and priority.
  • For privacy, the webhook does not include the response text or the ID number: fetch the details with pqrs.get.
  • If you sent an Idempotency-Key, the event’s X-Webhook-Id is stable across retries: use it to deduplicate (Webhook retries).
  • Use external_reference to find the case in your CRM without storing the UUID.

Sandbox #

With a sandbox API key, responses are simulated and deterministic:

CaseResult
Submit with document 1000000001 to 1000000004201; the same body produces the same id and filing number
Submit with document 9999999999422 AFFILIATE_NOT_FOUND
Respond to or change the status of PQRS-2026-999999Closed PQRS: respond → 409; move to another status → 409; closed → 200 (no-op)
An id that’s neither pqr-… nor a filing number404
List / statisticsA fixed inbox of 5 PQRS from the test documents (filters and pagination apply)

The sandbox doesn’t keep state: a write returns the new status, but a later GET for the same id goes back to the initial status (opened). pqrs.* webhooks aren’t emitted in the sandbox; to test your receiver, use a test event.

What’s next #