NSDL SEZ BOE Automation
NSDL SEZ BOE architecture & runbook
TEST stack · ap-south-1 · nsdl-sez-boe-automation-test

NSDL SEZ BOE/DTA Automation

A two-Lambda AWS pipeline that logs into NSDL's SEZ Online portal every day, downloads Bill of Entry and DTA Sale trade data, quality-checks and stages it per client, then pushes it into each client's Canofy/Frappe site as FTWZ BOE Import / FTWZ DTA Sale records with the original NSDL PDF attached.

2
LAMBDA FUNCTIONS
3
DYNAMODB TABLES
1
EXTERNAL PORTAL (NSDL)
6
QC CHECKS PER PIPELINE
900s
LAMBDA TIMEOUT (MAX)
N
CLIENT SITES (MULTI-TENANT)
00 — Overview

What this system does, in one pass

Every SEZ/FTWZ unit's import (Bill of Entry) and domestic-tariff-area sale (DTA Sale) transactions are recorded on NSDL's government SEZ Online portal, one entry at a time, with no bulk export API — only a browser UI. This automation replaces the manual "log in, export, re-key into Frappe, print, re-attach the PDF" routine with two scheduled Lambda functions either side of a DynamoDB staging table.

Phase A — Fetch

Once a day, logs into NSDL, exports BE + DTA for a rolling 7-day window, quality-checks every request, and stages new records per client in DynamoDB.

PhaseAFunction · EventBridge daily

Phase B — Deliver

Triggered per-client by that client's own Frappe site. Pushes staged records into Canofy, then logs back into NSDL to fetch and attach the official PDF for each BE record.

PhaseBFunction · API Gateway

Watchdog

Runs hourly. Flags any client whose records have sat in STAGED for more than 24 hours — either a dead Frappe scheduler or a chronically failing push.

WatchdogFunction · rate(1 hour)
Multi-tenant by design. One AWS deployment serves every client. Phase A stages records for all configured clients from a single NSDL login; Phase B is called separately by each client's own Frappe site, identifying itself via an X-Frappe-Client header so Phase B knows whose records to push and which Frappe credentials to use.
01 — Architecture

Architecture at a glance

Every resource in the deployed CloudFormation stack, and how data moves between them.

flowchart LR
    NSDL[["NSDL SEZ Online Portal
(external, no API)"]] subgraph AWS["AWS · nsdl-sez-boe-automation-test"] direction TB EB["EventBridge
cron, daily"] --> PA["PhaseAFunction
Playwright + EasyOCR"] PA --> S3["S3 RawFilesBucket
raw BE/DTA exports"] PA --> RCT["RunControlTable
per-day idempotency"] PA --> SRT[("StagedRecordsTable
per-record status")] PA --> RGT["RunGateTable
per-client QC summary"] APIGW["API Gateway
/sez/deliver-pending"] --> PB["PhaseBFunction
Playwright + EasyOCR"] PB --> SRT PB -.async failure.-> DLQ["SQS DLQ"] --> ALM1["CloudWatch Alarm"] WD["WatchdogFunction
hourly"] --> SRT WD --> SNS(["SNS AlertsTopic"]) ALM1 --> SNS ALM2["CloudWatch Alarm
PhaseA Errors"] --> SNS PA -.errors.-> ALM2 SEC[("Secrets Manager
NSDL creds + per-client Frappe creds")] PA --> SEC PB --> SEC end PA <--> NSDL PB <--> NSDL PB <--> FRAPPE["Client Frappe / Canofy site
FTWZ BOE Import · FTWZ DTA Sale"] FRAPPE -- "daily scheduled job" --> APIGW SNS --> HUMAN["Email / Slack
(human subscribes post-deploy)"] style SRT fill:#9a5f1b22,stroke:#9a5f1b style NSDL fill:#1f4e5c22,stroke:#1f4e5c style FRAPPE fill:#1f4e5c22,stroke:#1f4e5c
Fig. 1 — Every resource in the CloudFormation stack (template.yaml) and the two external systems it bridges: NSDL and each client's Frappe site.

Why two identical-looking Lambdas

Both PhaseA and PhaseB ship the same heavy container image (Playwright + Chromium + EasyOCR + pandas/opencv, ~4GB) because both need a real NSDL browser session — Phase A to export data, Phase B to fetch each record's official PDF. Neither is a lightweight HTTP function.

Why DynamoDB sits in the middle

Phase A never talks to Frappe, and Phase B never talks to NSDL's export screens. StagedRecordsTable is the sole handoff — this is what lets Phase A run once for every client while each client's Phase B call stays independent and retry-safe.

02 — Data model

A record's life, in one row of StagedRecordsTable

Every NSDL request (identified by its request_id) becomes exactly one row, hashed on request_id with a client_name-status-index GSI for Phase B's per-client queries.

STAGED→ PUSHED→ ATTACHED / QC_MISMATCH · error_type set, status unchanged
stateDiagram-v2
    [*] --> STAGED: Phase A stages a new,
QC-passed request_id STAGED --> QC_MISMATCH: check_6 IEC consistency
failed for this group STAGED --> PUSHED: Phase B — Frappe record
created or found (get_record) PUSHED --> ATTACHED: Phase B — NSDL PDF
fetched and attached STAGED --> STAGED: push failed —
error_type/retry_count set,
status unchanged, retried next run PUSHED --> PUSHED: PDF fetch/attach failed —
same retry-in-place pattern QC_MISMATCH --> [*] ATTACHED --> [*]
Fig. 2 — A failed push or PDF attach never moves the record to a dead-end status: it stays exactly where it was, with error_type / error_message / retry_count set, so the next invocation retries it automatically. The Watchdog is what catches a record stuck like this for too long.
FieldMeaning
request_idNSDL's own request ID — the table's hash key, and the natural idempotency key across every stage.
client_nameWhich onboarded client this belongs to — the GSI hash key for Phase B's per-client queries.
pipelineBE or DTA — decides the Frappe doctype and (formerly) the PDF step; both now share one path.
statusSTAGED · PUSHED · ATTACHED · QC_MISMATCH (see Fig. 2).
payloadThe full JSON-safe Frappe document body, built once at staging time and never re-derived.
frappe_docnameSet on PUSHED — the created/found Frappe document's real name, needed for the PDF-attach call.
error_type / error_message / retry_countSet by a failed attempt; status is deliberately left unchanged (see Fig. 2 caption).
ttlOnly ever set on ATTACHED (STAGED_TTL_DAYS, default 60) — non-terminal rows must never expire mid-retry.
03 — Phase A

Phase A — Fetch

app/phase_a_fetch/ · one Lambda, one daily invocation, every client at once.

sequenceDiagram
    participant EB as EventBridge
    participant PA as PhaseAFunction
    participant RCT as RunControlTable
    participant NSDL as NSDL Portal
    participant S3 as S3
    participant Parse as Parser + QC
    participant SRT as StagedRecordsTable
    participant RGT as RunGateTable

    EB->>PA: cron daily · {force_refresh:false}
    PA->>RCT: get_run_control(run_date)
    alt already PASSED today
        RCT-->>PA: reuse existing S3 files
    else not yet run
        PA->>NSDL: login (Playwright + EasyOCR CAPTCHA)
        PA->>NSDL: export BILL OF ENTRY + DTA SALE
        PA->>S3: store raw exports
        PA->>RCT: mark PASSED
    end
    PA->>Parse: read_nsdl_export, partition_by_client(IEC map)
    Parse->>Parse: 6 QC checks per client per pipeline
    Parse->>SRT: put_staged_record (new request_ids only)
    Parse->>RGT: put_run_gate (per-client QC summary)
          
Fig. 3 — One Phase A invocation, from EventBridge's static payload to two DynamoDB writes.

Trigger & the date window

EventBridge's Schedule target can only carry a static Input, so it always sends {"force_refresh": false} — no run_date. lambda_handler.py defaults it to today's UTC date, then computes the actual NSDL query window:

to_date   = run_date - 1 day      # NSDL's own data lags by one day
from_date = to_date - 7 days       # rolling 7-day window
# entered into NSDL's date fields as DD/MM/YYYY
The one-day lag is load-bearing. NSDL's export for "today" is never fully populated yet. Querying up through run_date itself silently returns incomplete data — this was found and fixed against the real portal, not assumed.

Login & export

nsdl_download.py drives a real headless Chromium session: fills username/password, solves the CAPTCHA via ocr_engine.py (four OpenCV preprocessing variants fed to EasyOCR, confidence-weighted character-by-character consensus across variants), retries login up to MAX_LOGIN_ATTEMPTS times, then exports both modules (NSDL_MODULE_BE=17, NSDL_MODULE_DTA=26) to tab-delimited .xls files.

Parse, partition, six QC checks

nsdl_parser.py groups rows by REQUEST ID, resolves each row's CLIENT IEC against the secret's reverse IEC→client map (an IEC mapped to two different clients aborts the entire run — the one error class this pipeline never resolves silently), and runs six checks per pipeline per client:

1 · Header count

Input rows vs. grouped output headers reconcile.

2 · Invoice count

Every invoice line survives grouping.

3 · Item count

Every item line survives grouping.

4 · Shipment count

Non-null shipment rows only (per BE/DTA drop rule).

5 · Sum checks

Quantity, BCD, assessable value totals match pre/post transform.

6 · IEC consistency

Every row in one request_id agrees on CLIENT IEC after normalization.

A failed check 6 group is staged with status QC_MISMATCH and excluded from the batch. Any other failing check either aborts staging for that client/pipeline (STEP2_QC_STRICT=true, the default) or stages anyway with a logged warning.

04 — Phase B

Phase B — Deliver

app/phase_b_deliver/ · one Lambda, invoked independently per client.

sequenceDiagram
    participant FR as Client Frappe site
    participant NSR as NSDL Sync Run doc
    participant APIGW as API Gateway
    participant PB as PhaseBFunction
    participant SRT as StagedRecordsTable
    participant CAN as Canofy (Frappe API)
    participant NSDL as NSDL Portal

    FR->>NSR: insert(status=Triggered)
    FR->>APIGW: POST /sez/deliver-pending
X-Frappe-Client, {sync_run_name} APIGW->>PB: invoke — Event (async), 202 immediately PB->>SRT: query_actionable_by_client (STAGED + PUSHED) loop each STAGED record PB->>CAN: get_record → found? reuse : create_record PB->>SRT: mark_pushed(frappe_docname) end PB->>NSDL: login (once for the whole batch) loop pending PDFs, capped at MAX_PDF_ATTACHES_PER_INVOCATION PB->>NSDL: fetch print-PDF for request_id PB->>CAN: attach_pdf PB->>SRT: mark_attached(ttl) Note over PB,NSDL: every 15th download: 120s
keep-alive-scroll pause end PB->>NSR: update_audit_doc(status, counts)
Fig. 4 — One Phase B invocation for one client. The async 202 means Frappe never waits on this; the real outcome lands later via the NSDL Sync Run doc update.

Why the call is asynchronous

API Gateway's REST integration caps a synchronous Lambda proxy at 29 seconds. A real NSDL login plus throttled PDF downloads routinely runs minutes. The integration sets X-Amz-Invocation-Type: Event, so Frappe's scheduled job gets a bare 202 {"accepted": true} the instant AWS accepts the trigger — the actual delivery result arrives later, written back onto the same NSDL Sync Run document.

Idempotent push to Frappe

Before creating anything, Phase B calls frappe.get_record(doctype, request_id). Only if Frappe has nothing for that request_id does it call create_record. This is the only check that touches Frappe's real state — DynamoDB's own status is what decides whether Phase B even looks at a record at all.

Proven under an actual duplicate invocation. A production Lambda timeout mid-run triggered an automatic retry with the same 64-record batch. The retry pushed zero duplicate Frappe records — every one was already PUSHED — and correctly resumed PDF-attach for only the ones still outstanding.

PDF fetch & attach — one path for BE and DTA

pdf_fetch.py drives Search Request → request_id → Print on NSDL and renders the result to PDF via the browser's own print-to-PDF. BE and DTA requests use the identical navigation — an earlier build assumed DTA had no printable document at all; that assumption was wrong and has since been corrected (see Hardening).

Every PDF_PAUSE_EVERY (15) downloads, the batch pauses PDF_PAUSE_SECONDS (120s) — not idle, but scrolling the page gently, because a fully idle tab was observed tripping NSDL's own session timeout mid-run. A hard MAX_PDF_ATTACHES_PER_INVOCATION (30) cap keeps one invocation inside Lambda's 900s ceiling on a large backlog; anything past the cap stays PUSHED for the next invocation.

05 — Watchdog

Watchdog

app/watchdog/ · a plain zip Lambda, no browser needed.

Every hour, scans StagedRecordsTable for rows still STAGED more than 24 hours after staged_at, groups the count by client, and publishes one SNS message naming every affected client. One check, two real failure modes: a client's Frappe scheduler has silently stopped calling Phase B at all, or Phase B is repeatedly failing to push a specific record (which — by design — never changes that record's status, so it looks identical to "nobody's asking" from here).

06 — Frappe integration

The Frappe side

What lives in the client's own Canofy/Frappe app (canofy_tcs), not in this AWS stack.

flowchart LR
    HOOKS["hooks.py
scheduler_events.daily"] --> JOB["trigger_nsdl_sync()"] JOB --> CFG[("API Configuration - Canofy
doc: NSDL SEZ DELIVER PENDING")] CFG -.reads url / api_key / api_secret
+ X-Frappe-Client header.-> JOB JOB --> NSR["NSDL Sync Run
insert(status=Triggered)"] JOB --> POST["make_post_request()"] POST --> AWS["AWS API Gateway"] AWS -. later, async .-> NSR2["Phase B updates the
same NSDL Sync Run doc"]
Fig. 5 — Everything this integration needs — endpoint, credentials, and this site's own client identity — lives in one existing doctype record; nothing lives in site_config.json.

NSDL Sync Run (new DocType)

One audit row per trigger: run_date, client_name, status (Triggered → Delivered / No Pending Records / Failed), records_delivered, pdfs_attached, error_message. Created by the Frappe job, updated later by Phase B.

canofy_tcs/import_documents/doctype/nsdl_sync_run/

API Configuration - Canofy (existing DocType, reused)

One record named NSDL SEZ DELIVER PENDING holds the deployed API URL, the API key/secret, and this site's X-Frappe-Client value inside its existing "Additional Static Headers" JSON field — no new field, no file-system config.

canofy_tcs/canofy_tcs/doctype/api_configuration___canofy/

Deliberately not routed through the existing Canofy API Call auto-dispatch controller — that controller only ever fires for API configs whose name/service contains "EWAY" (hardcoded), so it would silently do nothing for an NSDL config without editing shared, already-in-production code. nsdl_sync_scheduled_job.py instead reuses the two genuinely generic pieces: frappe.integrations.utils.make_post_request and the API Configuration - Canofy doctype as a config store.

07 — Reference

Configuration reference

Every value that shapes this system's behavior, and where it lives.

CloudFormation parameters — human-confirmed, never guessed

ParameterPurposeCurrent (TEST)
EnvironmentTEST or PROD — suffixes every named resource so both stacks can coexist.TEST
S3RetentionDaysDays before a raw NSDL export expires from S3. No default — pending a real SEZ/customs recordkeeping minimum.90
DailyScheduleCronExpressionEventBridge schedule for Phase A. No default — needs a confirmed business UTC time.cron(0 9 * * ? *)

PhaseAFunction environment

VariableValue / sourceNotes
RAW_FILES_BUCKET!Ref RawFilesBucketPhysical S3 bucket name
RUN_CONTROL_TABLE!Ref RunControlTablePer-day idempotency
STAGED_RECORDS_TABLE!Ref StagedRecordsTableShared with Phase B
RUN_GATE_TABLE!Ref RunGateTablePer-client QC summary view
NSDL_BOE_SECRET_NAME!Ref NsdlBoeSecrets (ARN)boto3 accepts ARN or name
NSDL_BASE_URLsezonline-ndml.co.in/User/Login.aspxfw_verify's proven default
NSDL_MODULE_BE / NSDL_MODULE_DTA17 / 26NSDL's own module dropdown values
NSDL_ACCOUNT_KEYsiddhartha_ftwzKey into the secret's NSDL_ACCOUNTS block
MAX_LOGIN_ATTEMPTS / MAX_CAPTCHA_READ_ATTEMPTS8 / 6Retry ceilings
STEP2_QC_STRICTtrueWithhold a client/pipeline's records on any failed check
HOME/tmpReal Lambda sandbox user's own $HOME is read-only — see Hardening

PhaseBFunction environment

VariableValue / sourceNotes
STAGED_RECORDS_TABLE / NSDL_BOE_SECRET_NAME / NSDL_BASE_URL / NSDL_ACCOUNT_KEY / MAX_LOGIN_ATTEMPTS / MAX_CAPTCHA_READ_ATTEMPTSSame as Phase A.
STAGED_TTL_DAYS60TTL applied only at ATTACHED
PDF_PAUSE_EVERY / PDF_PAUSE_SECONDS15 / 120Throttle to avoid NSDL session expiry
MAX_PDF_ATTACHES_PER_INVOCATION30Caps one invocation's PDF work under the 900s ceiling
HOME/tmpSame real-Lambda constraint as Phase A

Secrets Manager shape — nsdl-boe-automation-secrets-{Environment}

{
  "NSDL_ACCOUNTS": {
    "siddhartha_ftwz": {
      "NSDL_USERNAME": "",
      "NSDL_PASSWORD": "",
      "entity_id": ""            // NSDL's own SEZ-unit ID, shown per-row in every export
    }
  },
  "CLIENTS": {
    "eap": {
      "client_iec_codes": ["300014953", "0300014953"],
      "FRAPPE_URL": "https://eapx.canofy.tech",
      "api_key": "",
      "api_secret": ""
    }
  }
}

Real credentials are never generated or guessed by this codebase — populated post-deploy via aws secretsmanager put-secret-value. An IEC listed under two clients aborts the whole Phase A run rather than resolving silently.

08 — Hardening

Hardening notes — Docker-local vs. real Lambda

Every one of these passed cleanly under sam local invoke and then failed the first time it ran as the actually-deployed Lambda — the gap between a normal Docker container and AWS Lambda's sandboxed execution environment. Fixed and verified against real invocations, in the order found.

#SymptomRoot causeFix
1numpy bool/int not JSON-serializable, crashing a DynamoDB write_dynamo_safe() only handled floatExtended to np.bool_ / np.integer
2Lambda return-value marshaling errorDynamoDB's Decimal + raw numpy QC values in the response bodymake_json_safe() + explicit int(version)
3BrowserContext.new_page: target closedLambda's real /dev/shm is far smaller than a local container's--disable-dev-shm-usage
4Playwright rejects a raw --user-data-dir arg.launch() doesn't accept that flag directlySwitched to launch_persistent_context()
5DropAllCapabilitiesOnCurrentThread() check failedChromium's zygote fork model isn't permitted in Lambda's container--no-zygote --disable-gpu
6[Errno 30] Read-only file system: /home/sbx_user1051Lambda runs as a non-root sandbox user; only /tmp is writable, but EasyOCR/torch/Chromium all assume a writable $HOMEHOME=/tmp set globally as a Lambda env var
7Object of type Decimal is not JSON serializable pushing to Frappeboto3 always deserializes DynamoDB numbers as Decimal on readmake_json_safe() extended for Decimal, applied at the read boundary
8DTA Sale records never got a PDFWrong assumption baked into an earlier build — NSDL's print flow is identical for BE and DTABoth pipelines now wait at PUSHED and share one PDF-attach path
9Incomplete data for "today"NSDL's own export lags one day behindto_date = run_date - 1
10900s hard timeout on a large PDF backlog~9s/PDF plus periodic 120s pauses adds up fastMAX_PDF_ATTACHES_PER_INVOCATION cap; excess safely deferred (idempotent resume)
09 — Setup

Setup checklist — what's done, what's left

  1. 1
    Deploy the AWS stack donesam build && sam deploy with S3RetentionDays and DailyScheduleCronExpression supplied.
  2. 2
    Populate real secrets doneNSDL TEST credentials + the eap client's Frappe URL/API key/secret, via put-secret-value.
  3. 3
    Verify Phase A + Phase B end-to-end doneReal NSDL login, real export, real Frappe push, real PDF attach — all verified against the actual deployed Lambdas, not just local invokes.
  4. 4
    Add NSDL Sync Run + register the scheduled job done — local dev benchMigrated onto the local frappe.local dev site. Not yet deployed to whatever actually hosts eapx.canofy.tech in production.
  5. 5
    Deploy those same file changes to production Frappe outstandingReal production hosting path for canofy_tcs is not yet identified from this session.
  6. 6
    Create the API Configuration - Canofy record outstandingNamed NSDL SEZ DELIVER PENDING, with the real deployed API URL, the real API Gateway key value, and {"X-Frappe-Client": "eap"} in its headers field.
  7. 7
    Subscribe to AlertsTopic outstandingNobody receives Watchdog/error alarms until a real email or Slack endpoint is subscribed.
10 — Open items

Known gaps, worth deciding on deliberately

No self-healing against a manually-deleted Frappe doc

DynamoDB's ATTACHED status is permanent ground truth — if a Frappe record is deleted by hand afterward, nothing re-checks or re-creates it automatically.

MAX_PDF_ATTACHES_PER_INVOCATION=30 is a first estimate

Conservative, based on one real 64-record/31-PDF run. Worth revisiting once real multi-day, multi-client volume data exists.

Cold-start EasyOCR model download

A fresh execution environment's /tmp starts empty, so every cold start re-downloads the OCR detection model (~minutes) before it can even attempt a CAPTCHA.

Unmapped IEC volume

Real NSDL data in this window included large row counts under IECs not yet configured for any onboarded client — correctly skipped, but worth checking whether any belong to a client to onboard.