Attention API¶
Applies to: All subscriptions
Purpose¶
Describe /api/attention — the API for compliance findings: what is open,
which documents are affected, who owns each item, and how it closed.
Three facts that decide how you use it:
- A finding cannot be closed by an API call saying "fixed". It closes by itself when Complifly re-checks and the problem is gone. The only manual close is a decision (for example "IRN not required"), and only for the kinds of finding that list one.
- Findings exist only where the scheduled check is switched on. Eight rules
are available today —
IRN_WINDOW,IRN_WINDOW_PASSED,DOC_REJECTED_NIC,QUARANTINE_HELD,EWB_EXPIRY,EWB_MISSING_FOR_IRN,EWB_PART_B_PENDINGandERP_WRITEBACK_FAILED(see Rules). Findings are created by the scheduled check, which is off until an operator enables it (COMPLIANCE_SWEEP_ENABLED). Until thenGET /summaryreturns no groups andsweepHealthy: false. A registration sees the screen built on this API only whereCOMPLIANCE_INTELLIGENCE_V2is switched on (off by default; see Feature Flags); every other registration still sees the earlier screen, served by the Compliance Alerts API. - An empty list is only trustworthy when
sweepHealthyistrue. If the scheduled check is switched off or stale, "no findings" means "not checked", not "nothing wrong".
These are application endpoints, not a versioned integration contract (see Endpoint Index).
Audience¶
Integration developers feeding findings into a ticketing or reporting system, and administrators scripting bulk acknowledgement.
Prerequisites¶
- API Authentication — a user token. There is no machine-to-machine access to this API: every lifecycle action is recorded against a named user.
- An
x-gstinheader naming one of the caller's GSTINs. The cross-tenant valueallis refused withGSTIN_REQUIRED. - Permissions (see Roles and Permissions):
| To | The caller's role needs |
|---|---|
| Read anything | intelligence.view (every role) and the document permission for that kind of finding (irn.view, ewb.view, …) — a finding about documents you cannot open is not returned |
| Acknowledge, assign, add a note | attention.act |
| Record a decision, reopen | attention.dispose and the document right for that finding (irn.generate, ewb.generate, …) |
| Run a check now | admin.view |
Reference¶
| Method | Path | Purpose |
|---|---|---|
GET |
/api/attention/summary |
Open findings grouped by rule — one group per alert |
GET |
/api/attention/findings |
The findings behind a group |
GET |
/api/attention/findings/{id} |
One finding: evidence, timeline, other open findings on the same document |
POST |
/api/attention/findings/{id}/acknowledge |
Take ownership |
POST |
/api/attention/findings/{id}/assign |
Assign to a user |
POST |
/api/attention/findings/{id}/unassign |
Remove the assignee |
POST |
/api/attention/findings/{id}/notes |
Add a note to the timeline |
POST |
/api/attention/findings/{id}/dispose |
Close with a recorded decision |
POST |
/api/attention/findings/{id}/reopen |
Reopen a decision (within 30 days) |
POST |
/api/attention/findings/bulk |
The same actions on up to 500 findings |
GET |
/api/attention/history |
Closed findings; ?format=csv downloads them |
GET |
/api/attention/findings?format=csv |
The open list as a file, with the same filters |
GET |
/api/attention/rules |
The kinds of finding the caller can see, and the decisions each allows |
GET |
/api/attention/assignees |
Users a finding on this GSTIN can be assigned to |
GET |
/api/attention/state |
When this GSTIN was last checked |
POST |
/api/attention/evaluate |
Check this GSTIN now (once a minute) |
GET |
/api/attention/overview |
Every registration the caller may see, one line each, with the totals added up |
GET |
/api/attention/notification-profiles/user |
The caller's own notification settings, and the lists a screen renders from |
GET |
/api/attention/notification-profiles/gstin/{gstin} |
Who is emailed about one registration |
GET |
/api/attention/notification-profiles/org/{companyId} |
Who is emailed for a whole organization |
PUT |
/api/attention/notification-profiles/user/{event}, /gstin/{gstin}/{event}, /org/{companyId}/{event} |
Create or replace one notification profile |
DELETE |
the same three paths | Remove one |
POST |
the same three paths, with /resend appended |
Send a new confirmation link to an address still waiting |
GET |
/api/attention/notifications/verify?token= |
No login. The link in a confirmation email |
GET |
/api/attention/notifications/unsubscribe?token= |
No login. The link at the foot of an email to an address |
Rules¶
A rule decides what is a finding, how severe it is, what closes it, and which
decisions it accepts. GET /rules returns the ones the caller can see.
ruleKey |
A finding is | Severity | Closes by itself when | Decisions accepted (resolutionCode) |
|---|---|---|---|---|
IRN_WINDOW |
A document that needs an IRN, has none, and has 7 days or fewer before the 30-day deadline. Same scope and location values as IRN_WINDOW_PASSED |
CRITICAL with 3 days or fewer left (the last day included), otherwise HIGH — recomputed every pass, so it escalates on its own |
An IRN is recorded; the document date is corrected out of the window; a quarantined document is accepted; the ERP re-sends it successfully. Crossing the deadline closes it (EXPIRED, close_reason: DEADLINE_PASSED) and opens an IRN_WINDOW_PASSED finding for the same document |
None. A dispose call returns 400 INVALID_RESOLUTION_CODE — generate the IRN instead |
ERP_WRITEBACK_FAILED |
An update Complifly tried to send to your ERP (an IRN, an e-way bill, a refusal) and gave up on, because of your side: the ERP refused it, its endpoint was unreachable until the retries ran out, or Complifly would not call the address. One finding per dead event (location: ERP_WRITEBACK). Causes Complifly must investigate are not listed |
HIGH |
The event is delivered after a requeue (REPLAY_DELIVERED); it is requeued (REQUEUED); it is skipped by an operator (SKIPPED_BY_OPERATOR); a later event for the same document and the same fact is delivered (SUPERSEDED); the event row is purged (SOURCE_DISCARDED) |
None. Replay, requeue and skip are done on the Operations screen, where a reason is mandatory |
EWB_PART_B_PENDING |
A live e-way bill generated more than 24 hours ago whose Part-B is missing: no vehicle number for a road movement (or no mode recorded), or no transport document for rail, air or ship. One finding per e-way bill. Bills already past the life of Part-A are not raised | HIGH with 24 hours or fewer to the due time, otherwise MEDIUM |
Part-B is entered (PART_B_UPDATED); the bill is cancelled (CANCELLED) or delivered (DELIVERED); the row is gone (SOURCE_DISCARDED); Part-A runs out (EXPIRED, PART_A_LAPSED) |
None. A dispose call returns 400 INVALID_RESOLUTION_CODE — enter the vehicle |
EWB_MISSING_FOR_IRN |
A document with no e-way bill, though goods are probably moving. Two kinds, one finding per document (location): an invoice with an IRN, goods on it and no e-way bill, where the document declares transport or its value is ₹50,000 or more, with the IRN acknowledged between 7 days and 4 hours ago (IRN_INVOICE); and a document that exists only to get an e-way bill whose latest attempt failed in a way the customer can fix (EWB_DOCUMENT) |
HIGH |
An e-way bill now exists (EWB_GENERATED); the IRN is cancelled (IRN_CANCELLED); the 7-day look-back elapses (EXPIRED, LOOKBACK_ELAPSED); the document is gone (SOURCE_DISCARDED); it is edited so it no longer qualifies (DOCUMENT_CHANGED) |
EWB_GENERATED_ELSEWHERE (needs evidence.ewbNo, 12 digits), NO_MOVEMENT_REQUIRED, BELOW_STATE_THRESHOLD, SERVICE_SUPPLY (each needs a note) |
EWB_EXPIRY |
A live e-way bill (status ACTIVE, ACT or SUCCESS) whose validity ends within 24 hours, or ended up to 8 hours ago and can still be extended. One finding per e-way bill |
CRITICAL with 8 hours or fewer left (including up to 8 hours past expiry), otherwise MEDIUM — recomputed every pass, so it escalates on its own |
The validity is extended past 24 hours (EXTENDED); the bill is delivered (DELIVERED) or cancelled (CANCELLED); the row is gone (SOURCE_DISCARDED). If the extension window closes while it is still active it closes EXPIRED / EXTENSION_WINDOW_LAPSED and is kept 8 years |
None. A dispose call returns 400 INVALID_RESOLUTION_CODE — extend the bill or mark it delivered |
QUARANTINE_HELD |
A document the customer sent that was refused before it became a document: an Excel row that failed validation (location: QUARANTINE) or an ERP push refused at the API (location: ERP_REJECTED). Anything blocked counts — no document-type or supply-type filter. Raised only after it has been held for 4 hours |
CRITICAL when the upload carries an IRN deadline (the document needs an IRN and does not already exist) with 3 days or fewer left, otherwise HIGH. Past the deadline it is HIGH. A hold whose only error is that the number already exists is MEDIUM and never CRITICAL |
The row is promoted to a document (PROMOTED); the ERP re-sends it successfully (SUPERSEDED); a newer upload of the same document replaces it (DOCUMENT_CHANGED); it is discarded, purged or gone (EXPIRED, SOURCE_DISCARDED) |
None. A dispose call returns 400 INVALID_RESOLUTION_CODE — correct or discard it in the Correction Cockpit |
DOC_REJECTED_NIC |
An invoice, credit note or debit note NIC refused because of its content, still inside the 30-day deadline. One finding per document. Only refusals the customer can fix — failures of NIC or the GSP (service under maintenance, "application error — try again", login errors) are not listed | CRITICAL with 3 days or fewer to the IRN deadline, otherwise HIGH |
An IRN is recorded; the document is resubmitted and NIC's latest answer is no longer a refusal of its content (RESUBMITTED); the document is edited out of scope or deleted; it crosses the deadline (EXPIRED, DEADLINE_PASSED — it then appears as IRN_WINDOW_PASSED) |
WITHDRAWN (needs a note): the document will not be reported |
IRN_WINDOW_PASSED |
A document that needs an IRN, is more than 30 days past its date, and has none. One finding per document, wherever it is stuck (location) |
HIGH |
An IRN is recorded; the document date is corrected so it is no longer overdue; a quarantined document is accepted or discarded; the ERP re-sends it successfully | REISSUED (needs evidence.newDocNo), IRN_NOT_REQUIRED, CANCELLED_IN_BOOKS, EXCEPTION_RECORDED (each needs a note) |
ERP_WRITEBACK_FAILED in detail
| Boundary | Customer action is here; platform health is not. Raised: the ERP answered and refused it (ERP_REJECTED, fixed by you), the endpoint was unreachable until retries ran out or Complifly refused the address (fixed by your administrator), and any cause that cannot be classified. Not raised: outcome AMBIGUOUS (the ERP may have received it) and delivery that simply gave up (DEAD) — Complifly investigates those. A whole connection failing is not a finding: it is the operations alert, a count per connection |
| Who fixes it | evidence.fixedBy (you, admin, or null when the Help Centre has no entry for the code) and evidence.help |
evidence |
eventType, subscriptionId, sourceSystem, externalRef, occurredAt, attempts, lastErrorCode, lastError, help, fixedBy |
| What closes it | Read from what the system does: a requeue returns the same event to the queue; a rebuild and replay leaves the dead event DEAD as the audit record and sends a new STATE_RESYNC, so the finding closes because a later delivery of the same fact (or a STATE_RESYNC) exists. A later delivery of a different fact does not close it — a delivered e-way bill does not tell your ERP the IRN |
| Timing | Dead-lettering, requeue and skip are seen at once. A replay that delivers a new event is seen at the next scheduled check |
| Needs to see it | admin.api or irn.generate (the tax team should know the ERP lacks the IRN) |
| Needs to act | Acknowledge, assign and note: attention.act. This rule defines no allowed decisions, so no decision can be recorded here by any role: a request to dispose of one is refused (400 INVALID_RESOLUTION_CODE, "cannot be closed by hand"). Before 2026-10-15 a 403 said the same thing through the roles (no role held both attention.dispose and admin.api); a Super Admin now holds both and gets the 400 instead |
| Enabled by | ERP_WRITEBACK_ENABLED for the registration. It is off by default; while it is off, no finding exists and any existing one expires |
| Kept | 1 year (OPERATIONAL) |
EWB_PART_B_PENDING in detail
| Part-B missing | Road (trans_mode 1, or none): no vehicle number. Rail, air, ship (2, 3, 4): no transport document number. A blank value counts as empty. The other mode's field is ignored: a vehicle on a rail bill does not count |
due_at |
Generation time + partAValidityDays (default 15), as a UTC instant. The 15 is not confirmed against NIC's current rule; it is configuration |
evidence |
ewbNo, docNo, generatedAt and partAValidUntil (UTC), partAValidityDays, transMode, missing (VEHICLE or TRANSPORT_DOCUMENT), transporterId |
| Time | ewb_dt is IST text in the same three formats as the validity; it is read as IST |
Beside EWB_EXPIRY |
A bill with no Part-B and a validity about to end carries both. findings counts both; uniqueDocuments counts it once; GET /findings/{id} lists the other under companions |
| Needs to see it | ewb.view |
| Kept | 1 year (OPERATIONAL) |
EWB_MISSING_FOR_IRN in detail
IRN_INVOICE |
Document type INV (configuration), latest e-invoice record SUCCESS / ACT / ACTIVE with no e-way bill number, no e-way bill row that is not cancelled, at least one goods line (a line flagged as a service does not count), and either the document declares transport (basis: DECLARED) or its value is at or above valueThreshold (50,000, basis: VALUE_THRESHOLD), acknowledged more than graceHours (4) and less than lookbackDays (7) ago |
EWB_DOCUMENT |
An e-way-bill-only upload, or a posted delivery challan / bill of supply, whose latest attempt failed with NIC's refusal or with our own data check (LOCAL_VALIDATION) and that still has no e-way bill. A fault on our side (LOCAL_ERROR) is not listed. The failure must be within the look-back. Needs no grace period: the user has already tried |
evidence (invoice) |
basis (DECLARED or VALUE_THRESHOLD: why goods are thought to be moving), irn, ackDt, lookbackEndsAt, goodsLines, totalLines, shipTo, valueThreshold; and ewbRefused when NIC has already refused an e-way bill for it |
evidence (document) |
failedAt, lookbackEndsAt, outcome (NIC or LOCAL_VALIDATION), errors[] (up to three: code, message, help), errorCode, error, fixedBy |
due_at |
null. This is a blocker, not a deadline |
| Needs to see it | ewb.view |
| Needs to decide it | attention.dispose and ewb.generate (Tax Controller, Logistics Manager) |
| Kept | 1 year (OPERATIONAL). A decision is kept 8 years (COMPLIANCE) and the document is not raised again |
An IRN alone never means "e-way bill required". The application has no rule that decides that; it only permits generating one once an IRN exists. This rule therefore needs a sign that goods are moving. A document that declares transport is the application's own signal: it is what makes the IRN request ask NIC for an e-way bill in the same call, so an IRN with such a declaration and no bill is a gap whatever its value. Without a declaration, the value is a weaker guess. A document with neither is not listed.
The value threshold is one number for everyone. E-way bill thresholds and exemptions vary by state and by goods, so some findings will not need a bill. The four decisions are how those are absorbed; a per-registration threshold is a later phase. The 50,000 figure, the 7-day look-back and the 4-hour grace period are configuration. They have not yet been confirmed against the current rules.
EWB_EXPIRY in detail
| Window | From extensionWindowHours (8) after expiry to horizonHours (24) before it. Both are configuration, not code |
| Time zone | ewb_valid_till is stored as IST wall-clock text in three formats; it is read as IST. due_at is the expiry as a UTC instant |
evidence |
ewbNo, docNo, status, validTill (UTC), validTillIst (as NIC shows it), extensionClosesAt (UTC), vehicleNo, fromPlace, toPincode, transporterId. A blank value is null |
| Countdown | GET /findings returns hours_left (negative once expired) and overdue_hours. They are computed when you ask; the evidence does not carry them |
| Document | When the bill was raised for a document in Complifly, the finding carries that document's number, customer and value |
| Not listed | Bills expired more than 8 hours ago and never marked delivered (see below), bills with no readable validity (Part-A only), delivered and cancelled bills |
| Needs to see it | ewb.view |
| Kept | 1 year (OPERATIONAL). A lapse is kept 8 years (COMPLIANCE) |
Expired bills that were never closed are not in this rule. On the development
database 243 live bills are more than 8 hours past expiry; nearly all will be delivered
goods nobody marked delivered. The Phase 1 EWB_EXPIRED alert counts them; this rule
does not, so they will not appear in the Attention Center after the cutover. Mark them
delivered (POST /api/ewb/{ewbNo}/mark-received) to keep the records tidy.
NIC's 8-hour extension window is the rule as we understand it, held as a parameter. It has not yet been confirmed against NIC's current advisory.
QUARANTINE_HELD in detail
| Sources | Excel: td_invoice_quarantine_header rows not Resolved, Archived or Purged (Ready For Reprocess is still held). ERP: td_ingest_rejection rows with status = OPEN and a GSTIN, except reason = DUPLICATE. ERP rejections with no GSTIN cannot be attributed to a registration and are not listed |
| Grace period | Nothing is raised until the row has been held for graceHours (default 4, configuration), so nobody is alerted mid-correction. It is measured from when the row arrived (UTC) |
| One per document | The newest row per document number per source. If that row is later closed and an older one for the same document is still open, the older one is raised |
evidence (both) |
heldSince, errorCount, errors[] (first three: field, message), documentExists, and when the upload carries an IRN deadline: needsIrn, daysLeft, deadline |
evidence (Excel) |
uploadJobId, uploadFile, uploadedBy, lifecycleStatus; each error also has excelRow. Errors come from the upload's validation log; a row with none falls back to its own summary text |
evidence (ERP) |
sourceSystem, reason, repeatCount, externalRef, lastSeenAt |
documentExists |
A document with the same number is already in Complifly. The row is still listed: a corrected re-upload blocked from replacing the original is held, and only the customer knows whether this is that or a leftover |
duplicateDocNoOnly |
Present and true when "document number already exists" is the only error. Nothing to correct — only to discard. Such a hold is MEDIUM (configuration: duplicateOnlySeverity), never CRITICAL, so it stays visible and countable without competing with holds that can be fixed |
due_at |
The IRN deadline when there is one; null otherwise |
| Needs to see it | irn.view |
| Kept | 1 year (OPERATIONAL) |
Beside the deadline rules: an upload held for 28 days is both QUARANTINE_HELD (why it is stuck) and IRN_WINDOW (when it is due). findings counts both; uniqueDocuments counts it once. GET /findings/{id} lists the other open findings on the same upload under companions.
DOC_REJECTED_NIC in detail
| In scope | Invoices, credit notes and debit notes in location: REJECTED only (NIC refused them). Not e-way-bill-only documents, not B2C, and not e-way bill refusals — those belong to a later e-way bill rule |
| What counts as "the customer can fix it" | The latest submission attempt is DATA_ERROR or STATUTORY. For documents refused before attempt tracking began (2026-08-20), the stored NIC code is classified the same way. Then refusals matching the rule's platformCodes / platformMessages (configuration, not code) are dropped, because NIC and the GSP report some platform failures as business errors — code 5002 is used both for a field validation and for "Application error", so the message decides |
evidence |
nicErrors[] (up to 3: code, message, and help — { key, fixedBy, nextStep } when the Help Centre has an entry for that code, otherwise null), nicErrorCode / nicError (the first one), fixedBy (you, admin, statute or support, from the first code with help), outcome, attemptNo, attemptedAt, daysLeft, deadline |
| A second refusal | The finding stays open with the new reason in evidence, and a CORRECTION_ATTEMPTED event is added to its timeline |
| Beside the deadline rules | One document can have this finding and an IRN_WINDOW finding. findings counts both; uniqueDocuments counts the document once. GET /findings/{id} lists the other open findings on the document under companions |
due_at |
The IRN deadline: 23:59:59 IST on the 30th day after the document date |
| Needs to see it | irn.view |
| Needs to decide it | attention.dispose and irn.generate |
| Kept | 1 year (OPERATIONAL). A recorded WITHDRAWN decision is kept 8 years like every decision |
IRN_WINDOW in detail
| In scope | As IRN_WINDOW_PASSED: invoices, credit notes and debit notes; not e-way-bill-only documents; not B2C |
evidence |
docDate, deadline, daysLeft (0 on the last day), and what refused it, as for IRN_WINDOW_PASSED |
due_at |
The deadline: 23:59:59 IST on the 30th day after the document date. hours_left in the list counts down to it |
| Severity | Calendar days, IST — a document turns CRITICAL at the start of the day it has 3 days left, not at an hour boundary. A change is recorded as a SEVERITY_CHANGED event and sets escalated_at. Acknowledging or assigning does not stop the clock |
| After the deadline | This finding closes EXPIRED / DEADLINE_PASSED, and the IRN_WINDOW_PASSED finding that replaces it carries this one's id in previous_finding_id — follow it to see the whole history of one document |
| Needs to see it | irn.view |
| Kept | 1 year (retention_class: OPERATIONAL). If the deadline is missed, the IRN_WINDOW_PASSED finding is the record kept for 8 years |
Bulk actions on IRN_WINDOW: acknowledge, assign and unassign work as
for any rule; dispose fails per item with INVALID_RESOLUTION_CODE and
changes nothing.
IRN_WINDOW_PASSED in detail
| In scope | Invoices, credit notes and debit notes. Not e-way-bill-only documents, and not supplies to consumers (B2C) |
location |
PENDING (never submitted), REJECTED (NIC refused it), QUARANTINE (failed upload checks), ERP_REJECTED (refused at the ERP API) |
| Not duplicated | A quarantined or ERP-rejected copy of a document that already exists is not a second finding |
evidence |
docDate, deadline, daysOverdue, and what refused it: nicErrorCode / nicError, or validationErrors[] + uploadFile, or erpErrors[] + sourceSystem |
due_at |
The deadline: 23:59:59 IST on the 30th day after the document date |
| Needs to see it | irn.view |
| Needs to decide it | attention.dispose and irn.generate |
| Kept | 8 years, however it closed (retention_class: COMPLIANCE) |
The 30-day limit is NIC's. Complifly applies it to every registration with e-invoicing enabled; applying it per registration is planned.
GET /overview¶
Every registration the caller may see, for a screen that is not inside one registration (the Launchpad). The registrations come from the verified token, never from the request. Only registrations switched onto the new Attention Center (COMPLIANCE_INTELLIGENCE_V2) are counted; the others are listed as v2: false and add nothing to the totals, because their numbers are a different count. v2: null means the flag could not be read.
| Field | Meaning |
|---|---|
registrations[] |
gstin, v2, and for a counted one uniqueDocuments, findings, valueAtRisk (null = unknown, never zero), severityTotals, worst, sweepHealthy, sweepReason, lastCheckedAt, and topGroups[] (three biggest: ruleKey, title, severity, uniqueDocuments, route). Most urgent first. A registration that failed to load has error: true; it does not fail the rest |
totals |
registrationsCounted, registrationsNotOnNewAttentionCenter, registrationsUnavailable, registrationsNotChecking, and the sums of uniqueDocuments, findings, valueAtRisk, severityTotals over the counted registrations |
truncated |
true when the caller has more than 100 registrations; the first 100 are shown |
Each registration is read through the same summary a single-GSTIN caller gets, with the caller's own role, so a role sees no more here than on the screen.
Notification profiles¶
Nothing is emailed until two things are both true: Complifly has switched on email sending for the company (the NOTIFICATION_DISPATCH_ENABLED flag, default off, Super Admin only), and a registration or organization profile for that event is on. Profiles are saved either way. The in-app bell is a separate thing: it is on for everyone by default and is not affected by either.
A profile says who is told about a finding event. There is no administration screen of its own: a person edits theirs in Settings & Profile > My Notifications, a registration's in the Operational Controls GSTIN Overrides drawer, and an organization's under Operational Controls > Company Defaults. These endpoints are what those screens call.
| Scope | Path | Whose profile | Who may change it |
|---|---|---|---|
user |
/user |
The caller's own. There is no id in this path: it is always the token's user | Anyone signed in, for themselves. A personal profile can only switch the bell off, opt in or out of email, or raise its lowest severity. It cannot name recipients |
gstin |
/gstin/{gstin} |
One registration | An organization administrator, or a holder of admin.manage_users, inside their own company |
org |
/org/{companyId} |
One organization | An organization administrator |
A Super Admin may reach any company, exactly as in Operational Controls. attention.act and admin.view are deliberately not the test: this is an administration setting.
The tenant boundary is the one Operational Controls enforces. A gstin or org in the path is checked against the token's own company before anything is read or written. A registration outside it is 403 GSTIN_NOT_PERMITTED (the same answer whether it belongs to someone else or does not exist), and another company is 403 COMPANY_NOT_PERMITTED. orgId and userId come only from the verified token, never from a header, query or body.
Which channel carries which event (the GET response lists this in channels, so a screen does not hardcode it):
| Event | Bell | Fires when | Told by default | |
|---|---|---|---|---|
ASSIGNED |
yes | yes | A finding is assigned (self-assignment sends nothing) | The assignee |
ESCALATED |
yes | yes | Severity rises | The owner |
REOPENED |
yes | yes | A decision is reopened | Whoever recorded it (not whoever reopened it) |
CRITICAL_CREATED |
yes | no | A critical finding is first found, in the last 24 hours | Everyone who may open that kind of finding. Only a person can configure it, for themselves: a gstin or org profile for it is 400 EVENT_HAS_NO_EMAIL |
DAILY_SUMMARY, WEEKLY_SUMMARY |
no | yes | At sendHourLocal (and sendWeekday for weekly) in timezone (default Asia/Kolkata) |
Recipients only |
A channel an event does not have is stored off whatever the body says. OVERDUE, which an earlier design had, is not an event: it is 400 INVALID_EVENT.
Governance is Platform → Organization → Registration → User, and an explicit Off above is never bypassed. The company's NOTIFICATION_DISPATCH_ENABLED is the platform level. Then:
| If this is Off | Result |
|---|---|
The organization's profile for the event (a row with enabled: false, or channelEmail: false) |
Nobody is emailed on any registration of the company. A registration cannot turn it back on: saving one On is 409 BLOCKED_BY_ORGANIZATION, and the registration GET lists the blocked events in orgOff |
| The registration's own profile | Nobody is emailed on that registration, even where the organization is On |
A person's own profile (channelEmail: false or enabled: false) |
That person is not emailed. Always works |
A profile that does not exist is "not set up", not Off: a registration may turn on what the organization never set up, and a person may opt in. A registration may always be stricter than the organization (saving it Off is always allowed). When nothing above is Off, the registration's own profile is used for that event, else the organization's; a person's channelEmail: true opts in for what is theirs, and null means no opinion, follow the registration or organization (only a person may send null; the other scopes store true). A person's choice can narrow, never bypass. The in-app bell is outside this hierarchy: it is set per person only.
GET returns { scope, scopeId, canEdit, profiles[], users[], events[], emailEvents[], summaryEvents[], channels, defaults, limits, roles[], canAddEmail, emailConfigured, dispatchEnabled, orgOff[] } (orgOff is the events the organization has Off, and is only filled for a gstin read). dispatchEnabled is the company's kill switch: show it above the settings, because "nothing arrived" has this one obvious cause.
PUT body: enabled, channelEmail, channelInapp, minSeverity (CRITICAL, HIGH, MEDIUM or null), sendHourLocal (0–23), sendWeekday (1 Monday to 7), timezone, sendWhenEmpty, recipients[] ({ "type": "USER" | "ROLE" | "EMAIL", "value": … }, gstin and org only, at most 50) and rowVersion (or an If-Match header). A stale rowVersion is 409 STALE_PROFILE. Recipients are replaced as a set; an address already confirmed stays confirmed. The response is { profile, confirmationsPending, confirmationsSent, confirmationsHeld }.
Who is told, and how:
| Rule | Detail |
|---|---|
| Roles resolve at send time | A ROLE recipient becomes the active users who hold that role on that registration and may open that kind of finding when the email is sent. Someone given the role later is included without editing the profile, and a role added to the product later is addressable with no change here |
| Email addresses | Only an organization administrator can add one. It receives nothing until its owner follows the confirmation link (valid 72 hours, once). The mail carries counts, severities and links, never a customer name, a document number or a value; every one has an unsubscribe link |
| The kill switch also holds the confirmation mail | While NOTIFICATION_DISPATCH_ENABLED is off for the company, saving an address returns confirmationsHeld: true and sends no confirmation, and resend sends none. Switch it on, then resend |
| Content | One email per recipient per event per check: up to 10 items, then a count |
| Delivery | Event emails are sent at most once: a ledger row is written before the send, and a failed send is recorded and not retried. A failed summary is retried within its period. A summary with nothing to report is not sent unless sendWhenEmpty, except that a summary for a registration whose checks are not running always says so |
| Only on the new Attention Center | A registration not on COMPLIANCE_INTELLIGENCE_V2 is never emailed: the links would open the earlier screen |
| Server switch | ATTENTION_NOTIFICATIONS_ENABLED on the server (see Environment Variables) stops the job for every company. Off: nothing sends, anywhere. The flag above is the per-company switch inside it |
| Audit | Every save and removal is written to the audit log (ATTENTION_NOTIFICATION_PROFILE_SET, ATTENTION_NOTIFICATION_PROFILE_REMOVED) against the company, with an address shown only as a…@example.com. It is not written to the Operational Controls change history, which is for controls |
Errors (all { code, message }):
| HTTP | code |
When | Fixed by |
|---|---|---|---|
| 400 | INVALID_EVENT, INVALID_SEVERITY, INVALID_HOUR, INVALID_WEEKDAY, INVALID_TIMEZONE, INVALID_GSTIN, INVALID_COMPANY |
A value is not one the API accepts | you |
| 400 | EVENT_HAS_NO_EMAIL |
A gstin or org profile for a bell-only event |
you |
| 400 | USER_PROFILE_HAS_NO_RECIPIENTS |
Recipients were sent with the user scope |
you |
| 400 / 422 | TOO_MANY_RECIPIENTS, INVALID_RECIPIENT_TYPE, INVALID_ROLE, INVALID_EMAIL |
A recipient is not valid | you |
| 401 | UNAUTHENTICATED |
No usable token | sign in again |
| 403 | SCOPE_NOT_PERMITTED |
The caller's role may not edit that scope | admin |
| 403 | GSTIN_NOT_PERMITTED, COMPANY_NOT_PERMITTED |
The registration or company is outside the caller's own | admin |
| 403 | EMAIL_RECIPIENT_NEEDS_ORG_ADMIN |
An address was added by someone who is not an organization administrator | admin |
| 404 | NOT_PENDING |
resend for an address that is already confirmed or not on the profile |
you |
| 404 | UNKNOWN_COMPANY |
An org path names a company that is not in the company catalogue |
admin |
| 404 | UNKNOWN_GSTIN |
A gstin path names a registration that belongs to no company. Refused for a Super Admin too: a setting for it would belong to nobody |
admin |
| 409 | STALE_PROFILE |
rowVersion did not match |
you, reload and retry |
| 409 | BLOCKED_BY_ORGANIZATION |
A gstin profile was saved On for an event the organization has switched Off |
admin: switch it on for the organization first |
| 422 | RECIPIENT_NOT_ELIGIBLE |
A named user is not an active user of this registration or organization, or has no email address | you |
The two links with no login look the secret up by its hash (confirm) or exact value (unsubscribe), work once, and show a plain page that never repeats the token or the address.
Lifecycle¶
| Status | Set by | Meaning |
|---|---|---|
OPEN |
Complifly | True now; nobody owns it |
ACKNOWLEDGED |
A user | Someone owns it. Still true; deadlines still run |
RESOLVED |
A user, with a decision | Closed by a recorded decision. Not raised again for that document |
AUTO_CLOSED |
Complifly | Re-checked and no longer true — the document was fixed |
EXPIRED |
Complifly | No longer actionable — deadline passed, document discarded, or the check does not apply to the GSTIN |
close_reason says why a finding closed (IRN_GENERATED, RESUBMITTED, DEADLINE_PASSED,
DISPOSITION, CONDITION_CLEARED, …). retention_class says how long the
closed finding is kept: COMPLIANCE (8 years — decisions and lapsed statutory
deadlines) or OPERATIONAL (1 year).
GET /summary¶
Optional ?domain=EINVOICE|EWAYBILL|INGEST|INTEGRATION. A domain narrows what is counted, not only what is listed: findings, uniqueDocuments, parties, valueAtRisk and severityTotals then describe that domain only, and the response carries domain.
{
"gstin": "29ABCDE1234F1Z5",
"findings": 35,
"uniqueDocuments": 21,
"parties": 9,
"valueAtRisk": 4120000,
"severityTotals": { "CRITICAL": 3, "HIGH": 30, "MEDIUM": 2, "LOW": 0 },
"lastCheckedAt": "2026-10-19T05:12:04.000Z",
"sweepHealthy": true,
"sweepReason": null,
"groups": [ {
"ruleKey": "IRN_WINDOW", "domain": "EINVOICE", "kind": "DEADLINE",
"severity": "CRITICAL", "counts": { "CRITICAL": 3, "HIGH": 14, "MEDIUM": 0, "LOW": 0 },
"total": 17, "uniqueDocuments": 17, "parties": 5, "owned": 5, "overdue": 0,
"earliestDueAt": "2026-10-04T18:29:59.000Z", "oldestDetectedAt": "2026-09-27T04:10:00.000Z",
"valueAtRisk": 1240000,
"title": "…", "reason": "…", "impact": "…",
"action": { "label": "…", "route": "/einvoice?attention=IRN_WINDOW" },
"canAct": true, "canDispose": false, "hasDispositions": false
} ]
}
| Field | Meaning |
|---|---|
findings vs uniqueDocuments |
A document flagged by two rules is two findings and one document. Report uniqueDocuments as "how many documents need attention" |
valueAtRisk (top level) |
Counted once per document. A document on two cards adds its value to each card but to the total only once, so the total is lower than the sum of the cards. null means no value is known, never zero |
groups[].valueAtRisk |
The value of that card's documents. Some rules have none (e-way bills not linked to a document, ERP updates): null |
groups[] order |
Severity, then earliest due date, then value at risk |
groups[].severity |
The worst severity in the group; counts gives the split |
owned |
Findings acknowledged or assigned |
sweepReason |
When sweepHealthy is false: SWEEP_DISABLED, NEVER_RUN, STALE (no completed check in two intervals) or LAST_RUN_FAILED |
canAct / canDispose |
What the calling user may do on this kind of finding |
Cached for up to 60 seconds per GSTIN; a lifecycle action or a completed check clears it.
GET /rules¶
One entry per rule the caller can see: ruleKey, title, domain, kind, helpKey, action (label, route), canAct, canDispose, hasDispositions, entityTypes, severityBasis (fixed: one severity; rule: decided per finding), retentionClassDefault, evalOrder, and resolutions — each with code, label, noteRequired and the evidence fields it needs (field, label, pattern), enough to build a decision form without another call.
GET /findings and GET /history¶
/findings returns open findings by default; /history returns closed ones.
| Query | Values | Default |
|---|---|---|
rule |
A ruleKey |
all visible rules |
status |
active (open + acknowledged), closed, or one status |
active (closed on /history) |
severity |
CRITICAL, HIGH, MEDIUM, LOW |
all |
assignee |
me, none, or a user id |
all |
location |
Where the document is stuck: PENDING, REJECTED, QUARANTINE, ERP_REJECTED, IRN_INVOICE, EWB_DOCUMENT, ERP_WRITEBACK. The e-way bill rules EWB_EXPIRY and EWB_PART_B_PENDING have none (null) |
all |
closeReason, resolutionCode, retentionClass |
exact value | all |
from, to |
ISO date-times — detection time on /findings, closing time on /history |
none |
q |
Text in document number, party name or GSTIN, e-way bill number | none |
sort |
priority, due_at, value, age, closed_at |
priority (severity, due date, value) |
page, size |
size up to 200 |
1, 50 |
idsOnly |
1: return only { total, limit, truncated, ids[] } for everything matching the filter, up to 500, for a bulk action. truncated: true means more match than one bulk call accepts; act in batches |
off |
format |
csv: a file of up to 50,000 rows. /history returns the closed items, /findings the open ones, with the same filters. The last two columns are Due (UTC) and Where |
JSON |
Each item carries the finding (finding_id, rule_key, doc_no, doc_date,
party_name, doc_value, severity, status, due_at, evidence,
assignee_user_id, lifecycle_version, …), four ageing figures and
companions — other open findings on the same document.
| Ageing field | Meaning |
|---|---|
age_hours |
Since the finding was first detected |
hours_left |
Until due_at (negative once overdue) |
overdue_hours |
Past due_at; null if not overdue |
escalation_age_hours |
Since severity last rose; null if it never has |
Lifecycle calls¶
All take a JSON body and return { "finding": {…}, "unchanged": false }.
| Call | Body | Notes |
|---|---|---|
acknowledge |
{ "note"? } |
Acknowledging an already-acknowledged finding succeeds with unchanged: true |
assign |
{ "userId", "note"? } |
userId must come from GET /assignees |
unassign |
— | |
notes |
{ "note" } |
Up to 1,000 characters |
dispose |
{ "resolutionCode", "evidence"?, "note"? } |
resolutionCode must be one that GET /rules lists for the finding's rule; some need evidence fields or a note |
reopen |
{ "note" } |
Opens a new finding linked to the old one; the decision stays on record |
Concurrency. Send the lifecycle_version you last read as an If-Match
header. If the finding changed since — a colleague acted, severity rose, or it
closed — the call fails with 409 STALE_FINDING. Omit the header to act
regardless. Routine re-checks do not change lifecycle_version.
Bulk. POST /findings/bulk with
{ "action": "acknowledge" | "assign" | "unassign" | "dispose", "findingIds": [ … ], … }
and the same fields as the single call. Each finding succeeds or fails on its
own; the response lists every outcome:
{ "action": "acknowledge", "requested": 3, "succeeded": 2, "failed": 1,
"results": [ { "findingId": "41", "ok": true, "status": "ACKNOWLEDGED" },
{ "findingId": "44", "ok": false, "code": "INVALID_TRANSITION", "message": "…" } ] }
Errors¶
| HTTP | code |
When | Fixed by |
|---|---|---|---|
| 400 | GSTIN_REQUIRED |
No single GSTIN selected (x-gstin missing or all) |
you |
| 400 | INVALID_RESOLUTION_CODE |
The decision is not one this kind of finding allows — or it allows none | you |
| 400 | INVALID_TRANSITION |
Bulk call with an unknown action, no ids, or more than 500 ids | you |
| 401 | TOKEN_MISSING |
No token | you |
| 403 | — | The role lacks attention.act / attention.dispose |
admin |
| 403 | RULE_NOT_PERMITTED |
The role holds the permission but not the document right for this finding | admin |
| 404 | FINDING_NOT_FOUND |
No such finding for this GSTIN, or the caller cannot see that kind | you |
| 409 | INVALID_TRANSITION |
The action does not fit the finding's status (e.g. acting on a closed finding) | you |
| 409 | STALE_FINDING |
If-Match did not match |
you — re-read and retry |
| 409 | NO_RULES_ENABLED |
POST /evaluate with no rules enabled |
support |
| 422 | EVIDENCE_REQUIRED |
A required evidence field or note is missing or malformed | you |
| 422 | ASSIGNEE_NOT_ELIGIBLE |
The user cannot be assigned findings on this GSTIN or of this kind | you |
| 429 | EVALUATE_RATE_LIMITED |
POST /evaluate twice within a minute |
you — wait |
| 500 | ATTENTION_ERROR |
Unexpected failure; nothing was changed | support |
A finding belonging to another GSTIN is always 404, never 403: the API does
not reveal whether it exists.
Validation¶
GET /summaryfor a GSTIN with rules enabled:sweepHealthyistrueandlastCheckedAtis within the last two check intervals.- For any group,
GET /findings?rule=<ruleKey>returnstotalequal to the group'stotal. - Acknowledge a finding, then
GET /findings/{id}: the timeline's last event isACKNOWLEDGEDwith your user id.
Troubleshooting¶
| Symptom | Cause | Action |
|---|---|---|
groups is empty and sweepReason is SWEEP_DISABLED |
The scheduled check is off on this installation (COMPLIANCE_SWEEP_ENABLED) |
An operator enables it — see Environment Variables. Requires server access |
sweepReason is STALE |
No check has completed for two intervals | An operator checks the application log for [ATTENTION] errors and the Observability dashboards for ATTENTION_SWEEP_FAILURE |
| A finding will not close after the document was fixed | It closes at the next check (default every 15 minutes), or within seconds when event-driven checks are enabled | Wait for the next check, or POST /evaluate |
dispose returns INVALID_RESOLUTION_CODE for every code |
That kind of finding allows no decisions — it closes only when the document is fixed | Fix the document |
| A user sees fewer groups than a colleague | Groups follow document permissions | Compare roles — see Roles and Permissions |
Related Articles¶
- Compliance Alerts API — what the earlier Attention Center screen uses
- Endpoint Index
- Platform Error Codes
- Housekeeping — how long closed findings are kept