Implementation guide
PQRS
Submit, look up, respond to and manage your affiliates’ PQRS (complaints and requests)—petitions, complaints, claims and suggestions—from your CRM or call center, with statutory deadlines, an audited history and pqrs.* events.
On this page
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 #
| Scope | Allows |
|---|---|
pqrs:read | List, view details with history, and statistics |
pqrs:submit | Submit PQRS on an affiliate’s behalf |
pqrs:manage | Respond 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.
| Operation | Route | Scope |
|---|---|---|
| Submit | POST/v1/gateway/pqrs/ | pqrs:submit |
| List | GET/v1/gateway/pqrs/ | pqrs:read |
| Statistics | GET/v1/gateway/pqrs/stats/ | pqrs:read |
| Details | GET/v1/gateway/pqrs/{pqrs_id}/ | pqrs:read |
| Respond | POST/v1/gateway/pqrs/{pqrs_id}/respond/ | pqrs:manage |
| Change status / priority | POST/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_progressandescalatedaccept responses. A response withis_final: truemoves the PQRS toresolved.resolvedcan only move toclosed;closedis 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"])import { CompenseClient } from "@compense/sdk";
const client = new CompenseClient({ apiKey: process.env.COMPENSE_API_KEY!, environment: "sandbox" });
const tx = await client.pqrs.submit({
document: "1000000001",
issueType: "claim",
serviceArea: "wallet",
subject: "Unrecognized charge",
description: "The affiliate reports a charge of 45.000 in the wallet that they don't recognize.",
electronicNotificationConsented: true, // what the affiliate answered
originChannel: "call_center",
externalReference: "HORIZONTE-CRM-88231",
agent: "ana.p",
idempotencyKey: "horizonte-crm-88231-radicar",
});
console.log(tx.data.radicado, tx.data.id, tx.data.status, tx.data.deadline);<?php
require __DIR__ . '/vendor/autoload.php';
use Compense\Sdk\CompenseClient;
$client = new CompenseClient(apiKey: getenv('COMPENSE_API_KEY'), environment: 'sandbox');
$tx = $client->pqrs->submit(
document: '1000000001',
issueType: 'claim',
serviceArea: 'wallet',
subject: 'Unrecognized charge',
description: 'The affiliate reports a charge of 45.000 in the wallet that they don\'t recognize.',
electronicNotificationConsented: true, // what the affiliate answered
originChannel: 'call_center',
externalReference: 'HORIZONTE-CRM-88231',
agent: 'ana.p',
idempotencyKey: 'horizonte-crm-88231-radicar',
);
echo $tx->data['radicado'], ' ', $tx->data['status'], ' ', $tx->data['deadline'], PHP_EOL;using Compense.Sdk;
using Compense.Sdk.Models;
using var client = new CompenseClient(
Environment.GetEnvironmentVariable("COMPENSE_API_KEY")!, CompenseEnvironment.Sandbox);
Transaction tx = await client.Pqrs.SubmitAsync(
document: "1000000001",
issueType: "claim",
serviceArea: "wallet",
subject: "Unrecognized charge",
description: "The affiliate reports a charge of 45.000 in the wallet that they don't recognize.",
electronicNotificationConsented: true, // what the affiliate answered
originChannel: "call_center",
externalReference: "HORIZONTE-CRM-88231",
agent: "ana.p",
idempotencyKey: "horizonte-crm-88231-radicar");
Console.WriteLine($"{tx.Data["radicado"]} {tx.Data["status"]} {tx.Data["deadline"]}");package main
import (
"context"
"fmt"
"log"
"os"
compense "github.com/Appiasoft/compense-saas-sdk-go"
)
func main() {
ctx := context.Background()
client, err := compense.NewClient(os.Getenv("COMPENSE_API_KEY"),
compense.WithEnvironment(compense.Sandbox))
if err != nil {
log.Fatal(err)
}
tx, err := client.PQRS.Submit(ctx, &compense.PQRSSubmitParams{
Document: "1000000001",
IssueType: "claim",
ServiceArea: "wallet",
Subject: "Unrecognized charge",
Description: "The affiliate reports a charge of 45.000 in the wallet that they don't recognize.",
ElectronicNotificationConsented: true, // what the affiliate answered
OriginChannel: "call_center",
ExternalReference: "HORIZONTE-CRM-88231",
Agent: "ana.p",
}, compense.WithIdempotencyKey("horizonte-crm-88231-radicar"))
if err != nil {
log.Fatal(err)
}
fmt.Println(tx.Data["radicado"], tx.Data["status"], tx.Data["deadline"])
}curl -X POST https://sandbox.dev.compense.co/gw/v1/gateway/pqrs/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Idempotency-Key: horizonte-crm-88231-radicar" \
-H "Content-Type: application/json" \
-d '{
"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 do not recognize.",
"electronic_notification_consented": true,
"origin_channel": "call_center",
"external_reference": "HORIZONTE-CRM-88231",
"agent": "ana.p"
}'| Field | Required | Values |
|---|---|---|
document | yes | The affiliate’s ID number (must exist in your synced database) |
issue_type | yes | petition · complaint · claim · suggestion |
service_area | yes | subsidies · credits · health · marketplace · benefits · wallet · payments · general |
subject | yes | Up to 255 characters |
description | yes | Free text |
electronic_notification_consented | yes | true / false |
origin_channel | no (default api) | call_center · crm · phone · email · in_person · chat · whatsapp · web · api · other |
external_reference | no | The case id in your CRM (up to 100). Used for filtering and included in webhooks |
agent | no | Your agent’s reference, for traceability (don’t use email addresses) |
related_type + related_*_id | no | Link 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):
{
"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").resultsconst vencidas = await client.pqrs.list({ status: "opened", overdue: true, pageSize: 50 });
console.log(vencidas.count);
// `for await` walks through every page
for await (const p of vencidas) {
console.log(p.radicado, p.service_area, p.deadline, p.external_reference);
}
const caso = (await client.pqrs.list({ externalReference: "HORIZONTE-CRM-88231" })).results;$vencidas = $client->pqrs->list(status: 'opened', overdue: true, pageSize: 50);
echo $vencidas->count, PHP_EOL;
foreach ($vencidas->results as $p) {
echo $p['radicado'], ' ', $p['service_area'], ' ', $p['deadline'], PHP_EOL;
}
$caso = $client->pqrs->list(externalReference: 'HORIZONTE-CRM-88231')->results;using System.Text.Json.Nodes;
// All overdue cases, page by page
await foreach (JsonObject p in client.Pqrs.ListAutoPagingAsync(status: "opened", overdue: true, pageSize: 50))
{
Console.WriteLine($"{p["radicado"]} {p["service_area"]} {p["deadline"]}");
}
Page caso = await client.Pqrs.ListAsync(externalReference: "HORIZONTE-CRM-88231");it := compense.NewPageIterator(ctx, func(ctx context.Context, page int) (*compense.Page, error) {
return client.PQRS.List(ctx, &compense.PQRSListParams{
Status: "opened", Overdue: compense.Bool(true), Page: page, PageSize: 50,
})
})
for it.Next() {
p := it.Item()
fmt.Println(p["radicado"], p["service_area"], p["deadline"])
}
if err := it.Err(); err != nil {
log.Fatal(err)
}
caso, err := client.PQRS.List(ctx, &compense.PQRSListParams{ExternalReference: "HORIZONTE-CRM-88231"})
if err != nil {
log.Fatal(err)
}
fmt.Println(caso.Count)curl -G https://sandbox.dev.compense.co/gw/v1/gateway/pqrs/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
--data-urlencode "status=opened" \
--data-urlencode "overdue=true" \
--data-urlencode "page_size=50"{"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"])const stats = await client.pqrs.stats({ dateFrom: "2026-10-01", dateTo: "2026-10-31" });
console.log(stats.total, stats.overdue, stats.by_status, stats.by_service_area);$stats = $client->pqrs->stats(dateFrom: '2026-10-01', dateTo: '2026-10-31');
echo $stats['total'], ' overdue: ', $stats['overdue'], PHP_EOL;JsonObject stats = await client.Pqrs.StatsAsync(dateFrom: "2026-10-01", dateTo: "2026-10-31");
Console.WriteLine($"{stats["total"]} overdue: {stats["overdue"]}");stats, err := client.PQRS.Stats(ctx, &compense.PQRSStatsParams{DateFrom: "2026-10-01", DateTo: "2026-10-31"})
if err != nil {
log.Fatal(err)
}
fmt.Println(stats["total"], stats["overdue"], stats["by_status"])curl -G https://sandbox.dev.compense.co/gw/v1/gateway/pqrs/stats/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
--data-urlencode "date_from=2026-10-01" \
--data-urlencode "date_to=2026-10-31"{"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"])const detalle = await client.pqrs.get("PQRS-2026-000123"); // or the id (UUID)
for (const h of detalle.history as Array<Record<string, unknown>>) {
console.log(h.at, h.from_status, "→", h.to_status, h.source, h.actor);
}$detalle = $client->pqrs->get('PQRS-2026-000123'); // or the id (UUID)
foreach ($detalle['history'] as $h) {
echo $h['at'], ' ', $h['from_status'] ?? '-', ' → ', $h['to_status'], ' ', $h['source'], PHP_EOL;
}JsonObject detalle = await client.Pqrs.GetAsync("PQRS-2026-000123"); // or the id (UUID)
foreach (JsonNode? h in detalle["history"]!.AsArray())
{
Console.WriteLine($"{h!["at"]} {h["from_status"]} → {h["to_status"]} {h["source"]}");
}detalle, err := client.PQRS.Get(ctx, "PQRS-2026-000123") // or the id (UUID)
if err != nil {
log.Fatal(err)
}
fmt.Println(detalle["status"], detalle["history"])curl https://sandbox.dev.compense.co/gw/v1/gateway/pqrs/PQRS-2026-000123/ \
-H "X-API-Key: $COMPENSE_API_KEY"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"]) # resolvedconst tx = await client.pqrs.respond("PQRS-2026-000123", {
responseText: "The charge was reversed on October 28. You'll see the refund in your wallet.",
isFinal: true,
agent: "ana.p",
idempotencyKey: "horizonte-crm-88231-resp-1",
});
console.log(tx.data.status); // resolved
$tx = $client->pqrs->respond(
'PQRS-2026-000123',
responseText: 'The charge was reversed on October 28. You\'ll see the refund in your wallet.',
isFinal: true,
agent: 'ana.p',
idempotencyKey: 'horizonte-crm-88231-resp-1',
);
echo $tx->data['status'], PHP_EOL; // resolved
Transaction tx = await client.Pqrs.RespondAsync(
"PQRS-2026-000123",
"The charge was reversed on October 28. You'll see the refund in your wallet.",
isFinal: true, agent: "ana.p", idempotencyKey: "horizonte-crm-88231-resp-1");
Console.WriteLine(tx.Data["status"]); // resolvedtx, err := client.PQRS.Respond(ctx, "PQRS-2026-000123", &compense.PQRSRespondParams{
ResponseText: "The charge was reversed on October 28. You'll see the refund in your wallet.",
IsFinal: compense.Bool(true),
Agent: "ana.p",
}, compense.WithIdempotencyKey("horizonte-crm-88231-resp-1"))
if err != nil {
log.Fatal(err)
}
fmt.Println(tx.Data["status"]) // resolvedcurl -X POST https://sandbox.dev.compense.co/gw/v1/gateway/pqrs/PQRS-2026-000123/respond/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Idempotency-Key: horizonte-crm-88231-resp-1" \
-H "Content-Type: application/json" \
-d '{"response_text": "The charge was reversed on October 28. You will see the refund in your wallet.", "is_final": true, "agent": "ana.p"}'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",
)const tx = await client.pqrs.changeStatus("PQRS-2026-000123", {
status: "escalated",
priority: "high",
note: "Transferred to the payments area",
agent: "luis.r",
idempotencyKey: "horizonte-crm-88231-estado-1",
});$tx = $client->pqrs->changeStatus(
'PQRS-2026-000123',
status: 'escalated',
priority: 'high',
note: 'Transferred to the payments area',
agent: 'luis.r',
idempotencyKey: 'horizonte-crm-88231-estado-1',
);Transaction tx = await client.Pqrs.ChangeStatusAsync(
"PQRS-2026-000123", "escalated", priority: "high", note: "Transferred to the payments area",
agent: "luis.r", idempotencyKey: "horizonte-crm-88231-estado-1");tx, err := client.PQRS.ChangeStatus(ctx, "PQRS-2026-000123", &compense.PQRSStatusParams{
Status: "escalated", Priority: "high", Note: "Transferred to the payments area", Agent: "luis.r",
}, compense.WithIdempotencyKey("horizonte-crm-88231-estado-1"))
if err != nil {
log.Fatal(err)
}
fmt.Println(tx.Data["status"], tx.Data["priority"])curl -X POST https://sandbox.dev.compense.co/gw/v1/gateway/pqrs/PQRS-2026-000123/status/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Idempotency-Key: horizonte-crm-88231-estado-1" \
-H "Content-Type: application/json" \
-d '{"status": "escalated", "priority": "high", "note": "Transferred to the payments area", "agent": "luis.r"}'An invalid transition returns 409 («No se puede pasar de “cerrada” a “abierta”.» — “Can’t move
from ‘closed’ to ‘open’.”).
Common errors #
| HTTP | Engine code | When | detail (example) |
|---|---|---|---|
401 | — | Missing X-API-Key | — |
403 | — | API key without the scope, or module not enabled | El módulo PQRS no está contratado para esta entidad. |
404 | PQRS_NOT_FOUND | PQRS doesn’t exist or belongs to another entity | PQRS no encontrada. |
409 | INVALID_STATE | Responding to a resolved or closed PQRS | No se puede responder una solicitud cerrada. |
409 | INVALID_TRANSITION | Status transition not allowed | No se puede pasar de “cerrada” a “abierta”. |
409 | — | The same Idempotency-Key is still in progress | A request with this Idempotency-Key is already being processed. |
422 | VALIDATION_ERROR | Missing fields or values outside the catalog | Datos inválidos — subject: Este campo es requerido. |
422 | AFFILIATE_NOT_FOUND | The affiliate doesn’t exist in your entity | No existe un afiliado con ese documento en la entidad. Sincronícelo primero. |
422 | — | related_* belonging to another affiliate | — |
429 | — | Per-minute limit or monthly quota | see Retry-After |
502 / 504 | — | The engine failed or didn’t respond | Retry 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 #
| Event | Since | When it's emitted |
|---|---|---|
pqrs.created | 2026-10-27 | A PQRS was filed. |
pqrs.status_changed | 2026-10-27 | A PQRS changed status (including moving to resolved through a final response). |
pqrs.responded | 2026-10-27 | The entity recorded an official response. |
{
"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.createdalso includesissue_type,service_area,status,priority,origin_channel,affiliate_idanddeadline;pqrs.status_changedincludesfrom_status,to_statusandpriority.- 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’sX-Webhook-Idis stable across retries: use it to deduplicate (Webhook retries). - Use
external_referenceto find the case in your CRM without storing the UUID.
Sandbox #
With a sandbox API key, responses are simulated and deterministic:
| Case | Result |
|---|---|
Submit with document 1000000001 to 1000000004 | 201; the same body produces the same id and filing number |
Submit with document 9999999999 | 422 AFFILIATE_NOT_FOUND |
Respond to or change the status of PQRS-2026-999999 | Closed PQRS: respond → 409; move to another status → 409; closed → 200 (no-op) |
An id that’s neither pqr-… nor a filing number | 404 |
| List / statistics | A 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.