Bulk import files
The bulk import page (/records/import) reads a records file exported from a student information system and, after a run, writes a result file and a package archive. All of this happens in the issuer's browser; no file goes to a server. The flow is described in Bulk import.
| File | Direction | Format |
|---|---|---|
| Records file | In | CSV, JSON, or a mapped CSV export, read by an adapter |
| Document folder | In | A folder of PDF, PNG or JPEG files named by the records file |
| Result file | Out, and back in to carry on a run | import-result.csv |
| Package archive | Out | credential-packages.zip |
Records file
Fields
| Field | Required | Limit | Meaning |
|---|---|---|---|
externalId | yes | 100 characters | The row's own key in the source system. Unique in the file. |
holderName | yes | 200 | The holder's name, kept in the credential package |
studentId | yes | 100 | The student id. Unique in the file, compared after NFC, trim and upper case. |
recordType | yes | 200 | A built-in type or one of the issuer's own types, by name, case-insensitive |
programme | no | 500 | Display text for the package |
award | no | 500 | Display text for the package |
document | no | 255 | A plain file name in the chosen document folder. Empty means a credential without a document. |
awardedOn | no | 10 | Award date, YYYY-MM-DD, kept in the package and not on the chain |
Rules that stop a file
Every row is checked before the wallet is asked for anything, and every problem is listed with its row and field (File for problems with the whole file).
| Rule | Message (examples) |
|---|---|
| Required value missing | "The holder name is required" |
| Text too long | "The programme is longer than 500 characters" |
Starts with =, +, -, @, a tab or a carriage return | "Text may not start with = + - @ or a tab (a spreadsheet would run it as a formula)" |
| Control or hidden direction characters | "Text may not contain control characters or hidden text direction marks" |
| Unknown record type | The record type "..." is not a built-in type or one of your types |
| Name shared by two of the issuer's types | The record type "..." matches more than one of your record types; rename one of them |
| Document name is a path or starts with a dot | "The document must be a plain file name" |
| Document not in the folder | The document "..." is not in the chosen folder |
| Bad date | "The award date must be a calendar date written YYYY-MM-DD" |
| Repeated external id | The external id "..." already appears on row N |
| Repeated student id | "This student id already appears on row N (ids are compared without case or spaces)" |
A column or key spelled almost like a field (documents, awarded_on) | The column "..." looks like "..." but does not match it; rename it |
| More than 5,000 rows | "The file has more than 5000 records; split it" |
| More than 10 MB | "The file is larger than 10 MB." |
| No rows | "The file has no records" |
Warnings do not stop a run: columns or keys that are not used, a document file used by several rows (each of those holders then receives the key to the same file), and document names that were not checked because no folder was given ("The document files were not checked: no folder was given").
Adapters
An adapter (SisAdapter in src/lib/import/adapter-core.ts) turns text into records. It has an id, a label and parse(text), which returns records and warnings or a list of problems. Adapters read text only, with no network, key or clock, and the list is fixed in ADAPTERS (src/lib/import/adapters.ts).
| Id | Label in Export format | Reads |
|---|---|---|
csv | CSV file with the standard columns | CSV whose header row holds the field names, in any order and letter case |
json | JSON file: an array of records with the standard fields | A JSON array of objects. externalId and studentId may be whole numbers; other fields must be text. A leading byte order mark is ignored. |
template | Example export (Student No, Full Name, Programme, Credential, File, Awarded) | A CSV with other header names, mapped by createCsvAdapter. Student No fills both externalId and studentId; the template has no award column. |
The CSV reader is the project's own (src/lib/import/csv.ts): cells may be quoted with ", a doubled "" is a literal quote, a quote inside an unquoted cell is an error, and every row must have as many cells as the header. Cells are trimmed. A header that appears twice, or a required column that is missing, stops the file.
To support another system, call createCsvAdapter({ id, label, columns }) with that system's header names, add it to ADAPTERS, and add a test with a sample export.
Example CSV
Fake data.
externalId,holderName,studentId,recordType,programme,award,document,awardedOn
2026-0001,Asha Rai,S-1001,Degree,BSc Computer Science,First Class,asha-rai.pdf,2026-07-15
2026-0002,Bikash Gurung,S-1002,Transcript,BSc Computer Science,,bikash-gurung.pdf,2026-07-15
2026-0003,Chandra Tamang,S-1003,Certificate,Short Course in Data,,,
Example JSON
[
{ "externalId": 1, "holderName": "Asha Rai", "studentId": "S-1001", "recordType": "degree", "document": "asha-rai.pdf" },
{ "externalId": 2, "holderName": "Bikash Gurung", "studentId": "S-1002", "recordType": "Transcript" }
]
Checking without a wallet
npm run import:check -- records.csv
npm run import:check -- export.txt --adapter template --folder ./documents --types "Short Course, Transcript Addendum"
The command prints counts, warnings and every problem with its row and field, never the holders' details. It exits with 0 for a clean file, 1 for a file with problems and 2 for wrong arguments or an unreadable file. Issuer types are passed with --types and numbered from 1000 in the order given; the page reads them from the chain instead.
Row keys
A row is identified by what it would issue, not by its external id:
rowKey = first 16 bytes (32 hex) of keccak256( JSON.stringify([display, recordTypeId, documentName]) )
display holds the holder name, student id, programme, award and date. A corrected row is therefore a new row. The progress kept in the browser and the result file both use these keys, so neither holds a readable name or id. A separate record, keyed by a hash of the external id, remembers which credential an external id was last issued as; that is how a changed row is recognised.
Result file
import-result.csv, downloaded with Download result file. One line per row of the records file.
| Column | Meaning |
|---|---|
externalId | From the records file |
credentialId | The credential id when one exists |
state | issued, skipped (already done), recovered (found on chain), failed, unconfirmed or not-run |
reason | Why a row failed or is unconfirmed, or a note (for example a disclosure signature that was not added) |
rowKey | The row key above |
Every cell that starts with a formula character is prefixed with an apostrophe, and cells with commas, quotes or line breaks are quoted, so the file is safe to open in a spreadsheet.
Chosen again as Earlier result file, the file carries a run on in another browser. It is read as data, not trusted (readProgress in src/lib/import/progress.ts): rows in state issued, skipped or recovered become "issued" progress, unconfirmed rows become pending attempts, and every credential id is still confirmed on the chain for the issuer before a row is skipped. A file without the four columns, with a malformed row key, or listing a row twice is refused ("This is not a result file written by an earlier import.", "This result file lists the same row twice.").
externalId,credentialId,state,reason,rowKey
2026-0001,0x00000000000000000000000000000000000000000000000000000000000c0301,issued,,0123456789abcdef0123456789abcdef
2026-0002,,failed,The credential could not be issued,fedcba9876543210fedcba9876543210
2026-0003,,not-run,,00112233445566778899aabbccddeeff
Package archive
credential-packages.zip, downloaded with Download packages (N). It holds one credential package per credential issued in this run, named NNNN-<externalId>.json where NNNN is the row number padded to four digits and the external id is reduced to letters, digits, dot, underscore and hyphen. Packages of skipped or recovered rows are not in it: their salts and keys were never kept and cannot be rebuilt from the chain.
Each package holds the key that opens its holder's document and the holder's details. The page says: send each holder only their own package. Finish stays locked until the archive is downloaded, and the browser asks to confirm before leaving the page.
The zip is written by the project's own writer (src/lib/import/zip.ts).
Source files
src/lib/import/adapter-core.ts,adapters.ts,csv-adapter.ts,json-adapter.ts,csv.ts: readingsrc/lib/import/record.ts,validate.ts,prepare.ts,text.ts: limits and rulessrc/lib/import/plan.ts,store.ts: row keys and progress recordsrc/lib/import/outputs.ts,progress.ts,zip.ts: result file, archive, carrying onsrc/lib/import/check-cli.ts,scripts/import-check.ts:npm run import:checkdocs/import/README.md: the handbook guide
Tests
src/lib/import/adapters.test.ts,csv.test.ts,validate.test.ts,prepare.test.ts,plan.test.ts,outputs.test.ts,progress.test.ts,store.test.ts,check-cli.test.ts,changed.test.ts- E2E:
e2e/journeys/bulk-import.journey.ts