Write-back API¶
Applies to: ERP Write-back · Which modules do I have?
Purpose¶
Document how an ERP receives compliance outcomes — reference numbers, e-Way Bills, cancellations — so that the ERP stays in step with the compliance record without polling document status one at a time.
Audience¶
Integration developers.
Prerequisites¶
- API Authentication, with a working machine credential
ERP_WRITEBACK_ENABLEDset for the relevant registrations. It is off by default- An event subscription configured for your credential's registrations
Reference¶
Base path: /api/v1/integration
Two properties to design around¶
Emission is not retrospective. A tenant with write-back off stores no outbound events at all. Enabling it starts emission from that moment and does not backfill history. Seeding an ERP with existing compliance state is a separate exercise.
Delivery is at-least-once. An event may be delivered more than once. Your consumer must be idempotent on event_id. This is not a defensive nicety — it will happen.
The endpoints¶
| Method | Path | Purpose |
|---|---|---|
GET |
/events |
Pull pending events |
POST |
/events/ack |
Acknowledge processed events |
GET |
/health |
Feed health, including the backlog age |
Pulling events¶
GET /api/v1/integration/events?since=0&limit=100&types=IRN_GENERATED,EWB_GENERATED
X-API-Key: cfly_...
| Parameter | Meaning |
|---|---|
since |
Cursor. Pass the previous response's next_since |
limit |
Maximum events. Clamped to a supported range |
types |
Optional comma-separated filter |
Response:
{
"events": [
{
"event_id": "…",
"event_pk": 4821,
"event_type": "IRN_GENERATED",
"gstin": "29AAACW3775F000",
"doc_no": "INV-2026-0001",
"irn": "…",
"delivery": { "attempts": 1 }
}
],
"count": 1,
"next_since": 4821,
"has_more": false,
"subscriptions": [ { "subscription_id": 12, "gstin": "29AAACW3775F000" } ]
}
An unknown value in types narrows the result rather than erroring. That is deliberate: a consumer rolling out a new filter can never break its own feed with a typo — but it also means a filter typo produces silence, not an error. Check count against expectation when introducing a filter.
Acknowledging¶
POST /api/v1/integration/events/ack
Content-Type: application/json
{ "event_ids": ["…", "…"] }
Response reports how many were acknowledged and how many were not recognised:
{ "acked": 2, "unknown": 0, "unknown_ids": [] }
Acknowledge only after your ERP has durably stored the outcome. An event acknowledged before it is committed is an event that is lost if your process then fails — the platform has been told you have it.
An identifier belonging to another tenant simply does not match. It acknowledges nothing rather than acknowledging someone else's event, so a guessed identifier is harmless.
Event types¶
All 23 types this build emits are listed below. The list is checked against the same module the product itself reads — services/integration/eventTypes.js — and npm run docs:check:complete fails if this page and that module disagree. A type absent from a consumer's handling is a branch nobody wrote, so the whole catalogue is published rather than a representative sample.
The change column is the delta an event carries. It is not the full picture — every event except DOC_REJECTED also carries a state block holding the document's complete compliance state as of that moment, and the processing rules below explain why you apply state rather than change.
e-Invoice¶
| Event | Fires when | change carries |
|---|---|---|
IRN_GENERATED |
NIC returns a reference number | irn, ack_no, ack_dt, signed_qr_available, gsp, was_duplicate |
IRN_FAILED |
NIC refuses generation | error_code, error_message, class, retryable, revision_no, gsp, fix_hint, report_by |
IRN_CANCELLED |
A cancellation is accepted | irn, cancel_dt, cancel_reason, cancel_remark |
IRN_CANCEL_FAILED |
A cancellation is refused, usually past the 24-hour window | irn, error_code, error_message |
IRN_FAILED is the most consequential event in this feed, and the one most often missed — a consumer written around the success path has no branch for it, and it means the government refused the document. Three of its fields decide what you do next: retryable is true only for TRANSIENT and INDETERMINATE outcomes, so a false value means resubmitting the same payload will fail again; report_by is the last date this document can still be reported at all; and fix_hint is a plain-language hint that legitimately returns empty for error codes the help registry does not yet answer.
error_message is always populated; error_code is not. When class is TRANSIENT — the GSP or the NIC portal was unreachable, timed out, or returned a 5xx, and automatic retries were exhausted — there is no NIC error code, so error_code is null while error_message explains what could not be reached and retryable is true. Read error_message and class on every IRN_FAILED; treat error_code as present only for a DATA_ERROR or STATUTORY outcome.
e-Way Bill¶
| Event | Fires when | change carries |
|---|---|---|
EWB_GENERATED |
A bill is raised | ewb_no, ewb_dt, ewb_valid_till, irn_linked, gsp |
EWB_FAILED |
NIC refuses the bill | error_message, irn_linked, gsp — plus error_code on one of two paths, see below |
EWB_CANCELLED |
A bill is cancelled | ewb_no, cancel_dt, cancel_reason, cancel_remark |
EWB_PARTB_UPDATED |
Vehicle or transport details change | ewb_no, vehicle_no, trans_mode, trans_doc_no, trans_doc_dt, from_place, reason_code, reason_remark, version, new_valid_till |
EWB_EXTENDED |
Validity is extended | ewb_no, new_valid_till, remaining_distance, reason |
EWB_REJECTED |
The counterparty rejects the bill | ewb_no, rejected_by, rejected_at |
EWB_STATUS_CHANGED |
Marked received, or marked active again | ewb_no, from, to |
EWB_TRANSPORTER_UPDATED |
The assigned transporter changes | ewb_no, transporter_id |
EWB_EXPIRED |
Validity lapses without extension | ewb_no, expired_at, extended_times |
Verify:
EWB_FAILEDis emitted from two call sites with differentchangeshapes. The standalone e-Way Bill path includeserror_code; the path taken when a bill is attempted straight after an IRN emits onlyerror_message,irn_linkedandgsp. Treaterror_codeas optional on this type and readerror_messagein both cases. H1
EWB_EXPIRED deserves attention for the opposite reason to IRN_FAILED: nobody triggers it. A scheduled job emits it when validity lapses, so it arrives with no corresponding user action in your ERP and no request that caused it.
Consolidated e-Way Bill¶
Emitted per member document. A trip sheet covering forty invoices produces forty events, one against each document your system originated — not one event describing the trip sheet.
| Event | Fires when | change carries |
|---|---|---|
CEWB_GENERATED |
This document's bill is folded into a trip sheet | cewb_no, trip_sheet_no, vehicle_no, member_ewb_no, member_count |
CEWB_REGENERATED |
The trip sheet is regenerated — breakdown, re-route | cewb_no, previous_cewb_no, trip_sheet_no, vehicle_no |
CEWB_PARTB_UPDATED |
Consolidated Part-B is updated on its own | cewb_no, vehicle_no, from_place, from_state, trans_doc_no, trans_doc_dt, trans_mode, reason_code |
Multi-vehicle movement¶
For a consignment split across several vehicles on one leg. Also fanned out per document.
| Event | Fires when | change carries |
|---|---|---|
MULTIVEH_INITIATED |
A multi-vehicle group is opened | group_no, reason_code, total_qty, uqc |
MULTIVEH_VEHICLE_ADDED |
A leg is added to the group | group_no, leg_seq, vehicle_no, qty_in_vehicle, uqc |
MULTIVEH_VEHICLE_CHANGED |
A vehicle is swapped within the group | group_no, leg_seq, old_vehicle_no, new_vehicle_no, old_trans_doc_no, new_trans_doc_no, reason_code, reason_remark |
Document lifecycle¶
| Event | Fires when | change carries |
|---|---|---|
DOC_REJECTED |
A document is refused at ingest — validation, routing or GSTIN scope | reason, errors[] |
DOC_REVISED |
A failed document is corrected in Complifly's workbench rather than by your ERP | revision_no, source, changed_by, change_reason, changed_fields |
DOC_REJECTED is the one event with no document behind it. It carries document.inv_hdr_id: null and a rejection_id in its place, and no state block at all — a document refused before acceptance has no compliance state to report. A consumer that assumes a non-null document anchor will throw on this type and on no other. It also always carries event_seq: 1: repeat rejections are counted on the rejection row rather than re-emitted.
DOC_REVISED tells you about something your ERP did not do. It fires when someone corrected the document inside Complifly, which means your copy is now the stale one. Treat it as a prompt to re-read, never as an echo of your own change.
Housekeeping¶
Neither of these follows a user action. Both are safe to apply and wrong to notify on.
| Event | Fires when | change carries |
|---|---|---|
STATE_RESYNC |
The reconciler finds your copy diverged from ours, or an operator replays an event | reason — one of state_diverged, no_event_emitted, operator_replay — plus previous_event_type or original_event_type |
STATE_BACKFILL |
An explicit historical seeding run | is_backfill: true, backfill_run_id |
Suppress notifications for both. Apply the state quietly: no toast, no workflow trigger, no outbound mail. A backfill of two years of documents that sends one email per document is a support incident of its own making.
Log STATE_RESYNC distinctly, though. It means your copy had drifted and we corrected it, so a rising count is evidence that something in your consumer is dropping events.
Documented elsewhere, but never emitted¶
Two types appear in older design material and no code path emits either. They are named here so an integrator does not build a branch waiting for an event that never arrives, and does not read their absence as a defect:
| Type | Why it is absent |
|---|---|
DOC_SUPERSEDED |
The underlying succession mechanism — a cancelled document replaced by a new number — is not built |
CEWB_SUPERSEDED |
Left unwired deliberately. It would double-report the action CEWB_REGENERATED already covers |
Neither appears in the API's own type list, so a subscription filter cannot select them either.
Handling an unknown type¶
Log it, acknowledge it, and move on. New types are added over time, and a consumer that fails on an unrecognised type breaks on an upgrade it was not part of. Never leave an unknown event unacknowledged — it will be redelivered indefinitely.
Health¶
GET /health returns feed health per subscription and in aggregate. The number that matters is the age of the oldest pending event. A rising backlog age means your consumer has stopped, or has stopped acknowledging — a count alone will not tell you that, because a steady count can hide a stuck head-of-queue.
Monitor it. See Key Metrics and Thresholds.
Push delivery¶
Where configured, events are pushed to an ERP endpoint instead of pulled, with retry, circuit breaking and a dead-letter path. The circuit opens after consecutive failures and closes after a cooldown, so a failing endpoint does not consume the dispatcher indefinitely.
Push and pull carry the same events and the same guarantees. Pull is simpler to operate and does not require an inbound path to the ERP.
Validation¶
| Scenario | Expected | Your handling |
|---|---|---|
| First pull | Events returned, or an empty list | Cursor stored |
| Cursor advances | Second pull returns only newer events | No reprocessing |
| Acknowledgement works | acked matches what you sent |
Cursor and acknowledgement both correct |
| Duplicate delivery | The same event arrives twice | Processed once |
| Unknown event type | An unrecognised type appears | Logged and skipped, not fatal |
| No subscription | 404 NO_SUBSCRIPTION |
Reported as a configuration problem, not a transient one |
| Type filter | Filtered results | count matches expectation — a typo produces silence, not an error |
| Health check | Backlog age returned | Monitored and alerted |
| Consumer stops | Backlog age rises | Alert fires |
| Acknowledgement ordering | Acknowledge only after durable storage | An interrupted process reprocesses rather than losing an event |
Test the duplicate-delivery case explicitly. It is the single most important property of a write-back consumer and the easiest to leave untested.
Troubleshooting¶
| Symptom | Cause | Action |
|---|---|---|
| No events ever | Write-back disabled for the registration | Check the flag first. It is off by default |
| No events for older documents | Emission is not retrospective | Expected. Seeding is a separate exercise |
404 NO_SUBSCRIPTION |
No subscription for the credential's registrations | Configure one |
| Events stop arriving | Consumer stopped, or the cursor is stuck | Check your own process before escalating |
| Backlog age rising, count steady | The head of the queue is stuck | Investigate the oldest event specifically |
| Same event repeatedly | Not being acknowledged, or acknowledgement failing | Check the acknowledgement response |
| Events processed twice | Consumer not idempotent | Deduplicate on event_id |
| Events lost after a crash | Acknowledged before durable storage | Acknowledge only after committing |
| Filter returns nothing | A typo in types narrows silently |
Check the values against the event-type table |
| Push events stop after a burst of failures | The circuit breaker opened | Expected protection. Fix the endpoint; delivery resumes after cooldown |
| Dead-lettered events disappeared | Dead-letter retention shorter than your reaction time | Raise the retention. See Environment Variables |
| ERP out of step after an outage | Events dead-lettered while it was down | Replay from the administration surface. See Write-back Failures |
Related Articles¶
- Ingest API — the inbound direction
- Feature Flags — enabling write-back
- Write-back Failures — the diagnostic tree
- Key Metrics and Thresholds — what to alert on
- Integration Guides — per-ERP consumers