exportflow 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- exportflow-1.0.0/.gitignore +145 -0
- exportflow-1.0.0/CHANGELOG.md +22 -0
- exportflow-1.0.0/PKG-INFO +222 -0
- exportflow-1.0.0/README.md +206 -0
- exportflow-1.0.0/pyproject.toml +30 -0
- exportflow-1.0.0/src/exportflow/__init__.py +91 -0
- exportflow-1.0.0/src/exportflow/_client.py +614 -0
- exportflow-1.0.0/src/exportflow/_errors.py +140 -0
- exportflow-1.0.0/src/exportflow/_models.py +305 -0
- exportflow-1.0.0/src/exportflow/_version.py +11 -0
- exportflow-1.0.0/src/exportflow/py.typed +0 -0
- exportflow-1.0.0/src/exportflow/webhooks.py +87 -0
- exportflow-1.0.0/tests/test_client.py +389 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# ExportFlow — .gitignore
|
|
2
|
+
#
|
|
3
|
+
# This is the first commit in the repository, deliberately. Everything after it
|
|
4
|
+
# is safe by construction; doing it second would mean the first commit is the
|
|
5
|
+
# one that has to be audited.
|
|
6
|
+
#
|
|
7
|
+
# Banned from version control by standing rule (Foundation 1.6):
|
|
8
|
+
# secrets, databases, build artifacts, virtualenvs.
|
|
9
|
+
|
|
10
|
+
# --- Secrets -----------------------------------------------------------------
|
|
11
|
+
# .env.example is the one exception: the README's clean-machine bootstrap
|
|
12
|
+
# depends on it, so it must be committed.
|
|
13
|
+
.env
|
|
14
|
+
.env.*
|
|
15
|
+
!.env.example
|
|
16
|
+
.envrc
|
|
17
|
+
*.pem
|
|
18
|
+
*.key
|
|
19
|
+
*.p12
|
|
20
|
+
*.pfx
|
|
21
|
+
id_rsa*
|
|
22
|
+
credentials.json
|
|
23
|
+
service-account*.json
|
|
24
|
+
|
|
25
|
+
# --- Databases ---------------------------------------------------------------
|
|
26
|
+
*.db
|
|
27
|
+
*.sqlite
|
|
28
|
+
*.sqlite3
|
|
29
|
+
*.sqlite3-journal
|
|
30
|
+
*.dump
|
|
31
|
+
*.sql.gz
|
|
32
|
+
pgdata/
|
|
33
|
+
|
|
34
|
+
# --- Python ------------------------------------------------------------------
|
|
35
|
+
__pycache__/
|
|
36
|
+
*.py[cod]
|
|
37
|
+
*$py.class
|
|
38
|
+
*.so
|
|
39
|
+
.Python
|
|
40
|
+
build/
|
|
41
|
+
develop-eggs/
|
|
42
|
+
dist/
|
|
43
|
+
eggs/
|
|
44
|
+
.eggs/
|
|
45
|
+
sdist/
|
|
46
|
+
wheels/
|
|
47
|
+
share/python-wheels/
|
|
48
|
+
*.egg-info/
|
|
49
|
+
*.egg
|
|
50
|
+
MANIFEST
|
|
51
|
+
|
|
52
|
+
# --- Virtualenvs -------------------------------------------------------------
|
|
53
|
+
.venv/
|
|
54
|
+
venv/
|
|
55
|
+
ENV/
|
|
56
|
+
env/
|
|
57
|
+
.python-version.local
|
|
58
|
+
|
|
59
|
+
.ocr-check/
|
|
60
|
+
|
|
61
|
+
# --- Tooling caches ----------------------------------------------------------
|
|
62
|
+
.pytest_cache/
|
|
63
|
+
.mypy_cache/
|
|
64
|
+
.ruff_cache/
|
|
65
|
+
.hypothesis/
|
|
66
|
+
.import_linter_cache/
|
|
67
|
+
.coverage
|
|
68
|
+
.coverage.*
|
|
69
|
+
coverage.xml
|
|
70
|
+
htmlcov/
|
|
71
|
+
.tox/
|
|
72
|
+
.nox/
|
|
73
|
+
|
|
74
|
+
# --- Editors and OS ----------------------------------------------------------
|
|
75
|
+
.DS_Store
|
|
76
|
+
.DS_Store?
|
|
77
|
+
._*
|
|
78
|
+
.Spotlight-V100
|
|
79
|
+
.Trashes
|
|
80
|
+
Thumbs.db
|
|
81
|
+
.idea/
|
|
82
|
+
.vscode/
|
|
83
|
+
*.swp
|
|
84
|
+
*.swo
|
|
85
|
+
*~
|
|
86
|
+
|
|
87
|
+
# --- Real customer documents -------------------------------------------------
|
|
88
|
+
# UNREDACTED ORIGINALS. These are real commercial documents: consignee names,
|
|
89
|
+
# bank references, cargo values. They must never be committed.
|
|
90
|
+
#
|
|
91
|
+
# The brief described this directory as outside the repository. It is not — it
|
|
92
|
+
# sits at the repository root, and the root-anchored /*.pdf rule below does not
|
|
93
|
+
# reach into it, so one `git add -A` would have committed the lot.
|
|
94
|
+
#
|
|
95
|
+
# The redacted corpus lives at api/tests/fixtures/documents/ and is built from
|
|
96
|
+
# these; the originals stay here and stay ignored.
|
|
97
|
+
/sample_docs/
|
|
98
|
+
|
|
99
|
+
# --- Source and rendered documents -------------------------------------------
|
|
100
|
+
# docs/reference/ holds the binaries the written documentation was produced
|
|
101
|
+
# from or rendered to: the source workbooks, and the PDF/zip renderings of
|
|
102
|
+
# docs/*.md. They are inputs and artefacts, not sources, and they are large.
|
|
103
|
+
#
|
|
104
|
+
# NOT a blanket `*.pdf`: the Phase 3 golden corpus at
|
|
105
|
+
# api/tests/fixtures/documents/ is PDFs of real Bills of Lading, and a
|
|
106
|
+
# repository-wide rule would make the first one invisible to `git status`.
|
|
107
|
+
# Foundation 10.3 rates that corpus above any coverage number.
|
|
108
|
+
/docs/reference/
|
|
109
|
+
/*.pdf
|
|
110
|
+
/*.zip
|
|
111
|
+
|
|
112
|
+
# --- Node (web/ and mobile/, from Phase 4) -----------------------------------
|
|
113
|
+
node_modules/
|
|
114
|
+
.next/
|
|
115
|
+
.expo/
|
|
116
|
+
*.tsbuildinfo
|
|
117
|
+
|
|
118
|
+
# Manually acquired source documents. Real regulatory PDFs with real provenance,
|
|
119
|
+
# kept out of git deliberately: they are large, they are republished by their
|
|
120
|
+
# authorities, and their hashes are recorded in regulatory_document instead.
|
|
121
|
+
ops/sources/
|
|
122
|
+
|
|
123
|
+
# The pitch deck and its screenshots. 188 MB of .pptx, .pdf and full-page PNGs,
|
|
124
|
+
# regenerated from the running product rather than authored here, and git keeps
|
|
125
|
+
# every version of a binary for ever. They live on disk beside the docs that
|
|
126
|
+
# describe them; nothing in the build reads them.
|
|
127
|
+
/docs/pitch/
|
|
128
|
+
.vercel
|
|
129
|
+
.env*
|
|
130
|
+
|
|
131
|
+
# App Store Connect API keys
|
|
132
|
+
*.p8
|
|
133
|
+
|
|
134
|
+
# Local agent worktrees and session state
|
|
135
|
+
.claude/
|
|
136
|
+
|
|
137
|
+
# Generated by `make ai-golden` (synthetic, never committed)
|
|
138
|
+
ops/eval/ai_golden/cases/
|
|
139
|
+
ops/eval/ai_golden/manifest.json
|
|
140
|
+
ops/eval/ai-eval-report.json
|
|
141
|
+
|
|
142
|
+
# SDK build output
|
|
143
|
+
sdks/python/dist/
|
|
144
|
+
sdks/typescript/dist/
|
|
145
|
+
sdks/typescript/node_modules/
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to the ExportFlow Python SDK. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the SDK follows
|
|
5
|
+
[Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [1.0.0] - 2026-11-11
|
|
8
|
+
|
|
9
|
+
First release. Speaks version 1 of the ExportFlow API (`/public/v1`).
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- `ExportFlow` client with `shipments`, `documents`, `records`,
|
|
13
|
+
`verifications`, `usage` and `webhook_endpoints`.
|
|
14
|
+
- `documents.wait` and `verifications.wait` polling helpers.
|
|
15
|
+
- Automatic retries with backoff that reuse the request's idempotency key.
|
|
16
|
+
- Typed errors carrying the API's `code`, HTTP status and request id.
|
|
17
|
+
- `exportflow.webhooks.construct_event` and `verify` for signed deliveries.
|
|
18
|
+
- A check of the API's major version on every response.
|
|
19
|
+
- Detection of a file's media type from its content.
|
|
20
|
+
|
|
21
|
+
### Compatibility
|
|
22
|
+
- Python 3.9+. API version 1.x.
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: exportflow
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: The official Python SDK for the ExportFlow document processing and verification API
|
|
5
|
+
Project-URL: Documentation, https://exportflow.app/developers
|
|
6
|
+
Project-URL: Changelog, https://exportflow.app/developers/changelog
|
|
7
|
+
Author: MeerTech Ltd
|
|
8
|
+
License: Proprietary
|
|
9
|
+
Keywords: documents,erp,exportflow,ocr,trade,verification
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Typing :: Typed
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Requires-Dist: httpx<1,>=0.25
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# ExportFlow Python SDK
|
|
18
|
+
|
|
19
|
+
The official Python client for the ExportFlow API: upload trade documents from
|
|
20
|
+
your own software, have them classified and read, confirm what they say, and
|
|
21
|
+
verify them against each other.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install exportflow
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Python 3.9 or later. The only dependency is `httpx`.
|
|
28
|
+
|
|
29
|
+
## Quick start
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from exportflow import ExportFlow
|
|
33
|
+
|
|
34
|
+
client = ExportFlow(api_key="exfk_test_...") # or set EXPORTFLOW_API_KEY
|
|
35
|
+
|
|
36
|
+
# 1. Upload. The document is scanned, stored and queued for processing.
|
|
37
|
+
document = client.documents.upload(file="bill_of_lading.pdf")
|
|
38
|
+
|
|
39
|
+
# 2. Wait for processing (or use a webhook; see below).
|
|
40
|
+
document = client.documents.wait(document.id)
|
|
41
|
+
print(document.status, document.document_type) # processed bill_of_lading
|
|
42
|
+
|
|
43
|
+
# 3. Read what was extracted.
|
|
44
|
+
extraction = client.documents.extraction(document.id)
|
|
45
|
+
print(extraction.as_dict()) # {"bl_number": "MEDU7741203", ...}
|
|
46
|
+
|
|
47
|
+
# 4. Confirm what the documents say, then verify the shipment.
|
|
48
|
+
client.records.confirm(document.shipment_id, {"port_of_discharge": "NHAVA SHEVA"})
|
|
49
|
+
verification = client.verifications.create(document.shipment_id)
|
|
50
|
+
verification = client.verifications.wait(verification.id)
|
|
51
|
+
for finding in verification.findings:
|
|
52
|
+
print(finding.severity, finding.message)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
A key that begins `exfk_test_` works in the **sandbox**: the same pipeline,
|
|
56
|
+
separate data, never charged. A key that begins `exfk_live_` is **production**.
|
|
57
|
+
Create both in the developer console.
|
|
58
|
+
|
|
59
|
+
## How ExportFlow models documents
|
|
60
|
+
|
|
61
|
+
A **shipment** is a set of documents that travel together: a bill of lading, a
|
|
62
|
+
commercial invoice, a packing list, certificates. Documents are uploaded to a
|
|
63
|
+
shipment, each is **processed** (classified, then its fields extracted), and
|
|
64
|
+
the shipment is **verified** by checking the documents against each other and
|
|
65
|
+
against the published rule set.
|
|
66
|
+
|
|
67
|
+
`client.documents.upload(file=...)` with no `shipment_id` creates a shipment
|
|
68
|
+
for the document. To group several documents, create the shipment first:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
shipment = client.shipments.create(reference="PO-7741", origin_country="NG",
|
|
72
|
+
destination_country="IN")
|
|
73
|
+
for path in ("bl.pdf", "invoice.pdf", "packing_list.pdf"):
|
|
74
|
+
client.documents.upload(file=path, shipment_id=shipment.id)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Why you confirm before you verify
|
|
78
|
+
|
|
79
|
+
Verification refuses to start (`ConflictError`, code `not_confirmed`) until the
|
|
80
|
+
shipment's record has been confirmed. This is deliberate. If a field was
|
|
81
|
+
misread, every finding computed from it would be wrong, in both directions.
|
|
82
|
+
Show your user what was read (`client.records.get(shipment_id)`), let them
|
|
83
|
+
confirm or correct it, and send that with `client.records.confirm(...)`.
|
|
84
|
+
Confirm what the document *says*, not what it should say: verification is what
|
|
85
|
+
finds the error.
|
|
86
|
+
|
|
87
|
+
## Webhooks
|
|
88
|
+
|
|
89
|
+
Polling works; webhooks are better. Register an endpoint once:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
endpoint = client.webhook_endpoints.create(
|
|
93
|
+
"https://erp.example.com/hooks/exportflow",
|
|
94
|
+
event_types=["document.processing.completed", "verification.completed"],
|
|
95
|
+
)
|
|
96
|
+
print(endpoint.signing_secret) # whsec_... store this
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Verify every delivery before trusting it:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from exportflow import webhooks, SignatureVerificationError
|
|
103
|
+
|
|
104
|
+
def handle(request):
|
|
105
|
+
try:
|
|
106
|
+
event = webhooks.construct_event(
|
|
107
|
+
payload=request.body, # raw bytes
|
|
108
|
+
signature=request.headers["ExportFlow-Signature"],
|
|
109
|
+
secret=WEBHOOK_SECRET,
|
|
110
|
+
)
|
|
111
|
+
except SignatureVerificationError:
|
|
112
|
+
return 400
|
|
113
|
+
if already_handled(event.id): # deliveries are at-least-once
|
|
114
|
+
return 200
|
|
115
|
+
if event.type == "document.processing.completed":
|
|
116
|
+
document_id = event.data["document"]["id"]
|
|
117
|
+
...
|
|
118
|
+
return 200
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Answer with any `2xx` within 10 seconds. Anything else is retried with
|
|
122
|
+
increasing delays for about two and a half hours. An event carries identifiers
|
|
123
|
+
and statuses, never document content: fetch the resource to read it.
|
|
124
|
+
|
|
125
|
+
| Event | When |
|
|
126
|
+
|---|---|
|
|
127
|
+
| `document.uploaded` | A document was accepted, scanned and stored |
|
|
128
|
+
| `document.processing.started` | Classification began |
|
|
129
|
+
| `document.processing.completed` | The document was classified and read |
|
|
130
|
+
| `document.processing.failed` | Processing stopped; `GET` the document for why |
|
|
131
|
+
| `verification.completed` | A verification finished; fetch it for the findings |
|
|
132
|
+
| `verification.failed` | A verification could not run |
|
|
133
|
+
| `billing.invoice.generated` | An invoice was issued to your organisation |
|
|
134
|
+
|
|
135
|
+
## Errors
|
|
136
|
+
|
|
137
|
+
Every error is an `exportflow.ExportFlowError`. One returned by the API is an
|
|
138
|
+
`APIError` with a stable `code`, the HTTP `status_code` and a `request_id` to
|
|
139
|
+
quote to support. Branch on the type or the code, never on the message.
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
import exportflow
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
client.verifications.create(shipment_id)
|
|
146
|
+
except exportflow.ConflictError as error:
|
|
147
|
+
if error.code == "not_confirmed":
|
|
148
|
+
... # ask the user to confirm the record
|
|
149
|
+
except exportflow.QuotaExceededError:
|
|
150
|
+
... # the month's allowance is used up
|
|
151
|
+
except exportflow.RateLimitError as error:
|
|
152
|
+
time.sleep(error.retry_after or 5)
|
|
153
|
+
except exportflow.APIConnectionError:
|
|
154
|
+
... # safe to retry later
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
| Exception | HTTP | Meaning |
|
|
158
|
+
|---|---|---|
|
|
159
|
+
| `AuthenticationError` | 401 | Key missing, wrong, revoked or expired |
|
|
160
|
+
| `PermissionDeniedError` | 403 | Missing scope, suspended account, network not allowed |
|
|
161
|
+
| `NotFoundError` | 404 | No such resource in this organisation and environment |
|
|
162
|
+
| `ConflictError` | 409 | Not possible in the current state (e.g. `not_confirmed`) |
|
|
163
|
+
| `InvalidRequestError` | 400, 422 | The request is wrong; do not retry |
|
|
164
|
+
| `RateLimitError` | 429 | Slow down; see `retry_after` |
|
|
165
|
+
| `QuotaExceededError` | 429 | Allowance or spending limit reached for the period |
|
|
166
|
+
| `SDKVersionUnsupportedError` | 426 | Upgrade this SDK |
|
|
167
|
+
| `ServerError` | 5xx | ExportFlow's fault; retried automatically |
|
|
168
|
+
| `APIConnectionError`, `APITimeoutError` | none | The API could not be reached |
|
|
169
|
+
|
|
170
|
+
## Retries and idempotency
|
|
171
|
+
|
|
172
|
+
The client retries connection failures, timeouts, `429 rate_limited` and `5xx`
|
|
173
|
+
up to `max_retries` times (default 2), with backoff, honouring `Retry-After`.
|
|
174
|
+
|
|
175
|
+
Every request that changes something carries an `Idempotency-Key`, and a retry
|
|
176
|
+
sends the *same* key and the *same* body, so the work is done once however many
|
|
177
|
+
times it is sent. To make your own retries safe across process restarts, pass
|
|
178
|
+
your own key:
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
client.documents.upload(file="bl.pdf", shipment_id=sid, idempotency_key=f"upload-{erp_document_id}")
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Uploading the same file to the same shipment twice returns the first document
|
|
185
|
+
(`duplicate=True`) and is not charged again.
|
|
186
|
+
|
|
187
|
+
## Usage and charges
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
usage = client.usage.get() # this month; or client.usage.get("2026-10")
|
|
191
|
+
for meter in usage.meters:
|
|
192
|
+
print(meter.name, meter.succeeded, "of", meter.quota or "unlimited")
|
|
193
|
+
print(usage.estimated_charge, usage.currency)
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Requests are not charged. Usage is recorded when work completes: a document
|
|
197
|
+
accepted, a document processed, a verification completed. Sandbox usage is
|
|
198
|
+
measured and never charged. Charges are estimates until an invoice is issued.
|
|
199
|
+
|
|
200
|
+
## Configuration
|
|
201
|
+
|
|
202
|
+
```python
|
|
203
|
+
ExportFlow(
|
|
204
|
+
api_key=None, # default: EXPORTFLOW_API_KEY
|
|
205
|
+
base_url=None, # default: EXPORTFLOW_BASE_URL or https://api.exportflow.app
|
|
206
|
+
timeout=60.0, # seconds per response
|
|
207
|
+
max_retries=2,
|
|
208
|
+
http_client=None, # your own httpx.Client: proxies, TLS, tests
|
|
209
|
+
)
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
The client is safe to share between threads. Use it as a context manager, or
|
|
213
|
+
call `close()`, to release its connections.
|
|
214
|
+
|
|
215
|
+
## Versioning
|
|
216
|
+
|
|
217
|
+
This SDK follows semantic versioning and speaks version 1 of the API. Within
|
|
218
|
+
version 1 the API only adds; nothing is removed or changed in meaning, so this
|
|
219
|
+
SDK keeps working as the API grows, and unknown fields are available on every
|
|
220
|
+
model's `.raw`. If the server ever reports a different major version the
|
|
221
|
+
client raises `IncompatibleAPIVersionError` rather than misread a response.
|
|
222
|
+
See `CHANGELOG.md`.
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# ExportFlow Python SDK
|
|
2
|
+
|
|
3
|
+
The official Python client for the ExportFlow API: upload trade documents from
|
|
4
|
+
your own software, have them classified and read, confirm what they say, and
|
|
5
|
+
verify them against each other.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install exportflow
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Python 3.9 or later. The only dependency is `httpx`.
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from exportflow import ExportFlow
|
|
17
|
+
|
|
18
|
+
client = ExportFlow(api_key="exfk_test_...") # or set EXPORTFLOW_API_KEY
|
|
19
|
+
|
|
20
|
+
# 1. Upload. The document is scanned, stored and queued for processing.
|
|
21
|
+
document = client.documents.upload(file="bill_of_lading.pdf")
|
|
22
|
+
|
|
23
|
+
# 2. Wait for processing (or use a webhook; see below).
|
|
24
|
+
document = client.documents.wait(document.id)
|
|
25
|
+
print(document.status, document.document_type) # processed bill_of_lading
|
|
26
|
+
|
|
27
|
+
# 3. Read what was extracted.
|
|
28
|
+
extraction = client.documents.extraction(document.id)
|
|
29
|
+
print(extraction.as_dict()) # {"bl_number": "MEDU7741203", ...}
|
|
30
|
+
|
|
31
|
+
# 4. Confirm what the documents say, then verify the shipment.
|
|
32
|
+
client.records.confirm(document.shipment_id, {"port_of_discharge": "NHAVA SHEVA"})
|
|
33
|
+
verification = client.verifications.create(document.shipment_id)
|
|
34
|
+
verification = client.verifications.wait(verification.id)
|
|
35
|
+
for finding in verification.findings:
|
|
36
|
+
print(finding.severity, finding.message)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
A key that begins `exfk_test_` works in the **sandbox**: the same pipeline,
|
|
40
|
+
separate data, never charged. A key that begins `exfk_live_` is **production**.
|
|
41
|
+
Create both in the developer console.
|
|
42
|
+
|
|
43
|
+
## How ExportFlow models documents
|
|
44
|
+
|
|
45
|
+
A **shipment** is a set of documents that travel together: a bill of lading, a
|
|
46
|
+
commercial invoice, a packing list, certificates. Documents are uploaded to a
|
|
47
|
+
shipment, each is **processed** (classified, then its fields extracted), and
|
|
48
|
+
the shipment is **verified** by checking the documents against each other and
|
|
49
|
+
against the published rule set.
|
|
50
|
+
|
|
51
|
+
`client.documents.upload(file=...)` with no `shipment_id` creates a shipment
|
|
52
|
+
for the document. To group several documents, create the shipment first:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
shipment = client.shipments.create(reference="PO-7741", origin_country="NG",
|
|
56
|
+
destination_country="IN")
|
|
57
|
+
for path in ("bl.pdf", "invoice.pdf", "packing_list.pdf"):
|
|
58
|
+
client.documents.upload(file=path, shipment_id=shipment.id)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Why you confirm before you verify
|
|
62
|
+
|
|
63
|
+
Verification refuses to start (`ConflictError`, code `not_confirmed`) until the
|
|
64
|
+
shipment's record has been confirmed. This is deliberate. If a field was
|
|
65
|
+
misread, every finding computed from it would be wrong, in both directions.
|
|
66
|
+
Show your user what was read (`client.records.get(shipment_id)`), let them
|
|
67
|
+
confirm or correct it, and send that with `client.records.confirm(...)`.
|
|
68
|
+
Confirm what the document *says*, not what it should say: verification is what
|
|
69
|
+
finds the error.
|
|
70
|
+
|
|
71
|
+
## Webhooks
|
|
72
|
+
|
|
73
|
+
Polling works; webhooks are better. Register an endpoint once:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
endpoint = client.webhook_endpoints.create(
|
|
77
|
+
"https://erp.example.com/hooks/exportflow",
|
|
78
|
+
event_types=["document.processing.completed", "verification.completed"],
|
|
79
|
+
)
|
|
80
|
+
print(endpoint.signing_secret) # whsec_... store this
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Verify every delivery before trusting it:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from exportflow import webhooks, SignatureVerificationError
|
|
87
|
+
|
|
88
|
+
def handle(request):
|
|
89
|
+
try:
|
|
90
|
+
event = webhooks.construct_event(
|
|
91
|
+
payload=request.body, # raw bytes
|
|
92
|
+
signature=request.headers["ExportFlow-Signature"],
|
|
93
|
+
secret=WEBHOOK_SECRET,
|
|
94
|
+
)
|
|
95
|
+
except SignatureVerificationError:
|
|
96
|
+
return 400
|
|
97
|
+
if already_handled(event.id): # deliveries are at-least-once
|
|
98
|
+
return 200
|
|
99
|
+
if event.type == "document.processing.completed":
|
|
100
|
+
document_id = event.data["document"]["id"]
|
|
101
|
+
...
|
|
102
|
+
return 200
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Answer with any `2xx` within 10 seconds. Anything else is retried with
|
|
106
|
+
increasing delays for about two and a half hours. An event carries identifiers
|
|
107
|
+
and statuses, never document content: fetch the resource to read it.
|
|
108
|
+
|
|
109
|
+
| Event | When |
|
|
110
|
+
|---|---|
|
|
111
|
+
| `document.uploaded` | A document was accepted, scanned and stored |
|
|
112
|
+
| `document.processing.started` | Classification began |
|
|
113
|
+
| `document.processing.completed` | The document was classified and read |
|
|
114
|
+
| `document.processing.failed` | Processing stopped; `GET` the document for why |
|
|
115
|
+
| `verification.completed` | A verification finished; fetch it for the findings |
|
|
116
|
+
| `verification.failed` | A verification could not run |
|
|
117
|
+
| `billing.invoice.generated` | An invoice was issued to your organisation |
|
|
118
|
+
|
|
119
|
+
## Errors
|
|
120
|
+
|
|
121
|
+
Every error is an `exportflow.ExportFlowError`. One returned by the API is an
|
|
122
|
+
`APIError` with a stable `code`, the HTTP `status_code` and a `request_id` to
|
|
123
|
+
quote to support. Branch on the type or the code, never on the message.
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
import exportflow
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
client.verifications.create(shipment_id)
|
|
130
|
+
except exportflow.ConflictError as error:
|
|
131
|
+
if error.code == "not_confirmed":
|
|
132
|
+
... # ask the user to confirm the record
|
|
133
|
+
except exportflow.QuotaExceededError:
|
|
134
|
+
... # the month's allowance is used up
|
|
135
|
+
except exportflow.RateLimitError as error:
|
|
136
|
+
time.sleep(error.retry_after or 5)
|
|
137
|
+
except exportflow.APIConnectionError:
|
|
138
|
+
... # safe to retry later
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
| Exception | HTTP | Meaning |
|
|
142
|
+
|---|---|---|
|
|
143
|
+
| `AuthenticationError` | 401 | Key missing, wrong, revoked or expired |
|
|
144
|
+
| `PermissionDeniedError` | 403 | Missing scope, suspended account, network not allowed |
|
|
145
|
+
| `NotFoundError` | 404 | No such resource in this organisation and environment |
|
|
146
|
+
| `ConflictError` | 409 | Not possible in the current state (e.g. `not_confirmed`) |
|
|
147
|
+
| `InvalidRequestError` | 400, 422 | The request is wrong; do not retry |
|
|
148
|
+
| `RateLimitError` | 429 | Slow down; see `retry_after` |
|
|
149
|
+
| `QuotaExceededError` | 429 | Allowance or spending limit reached for the period |
|
|
150
|
+
| `SDKVersionUnsupportedError` | 426 | Upgrade this SDK |
|
|
151
|
+
| `ServerError` | 5xx | ExportFlow's fault; retried automatically |
|
|
152
|
+
| `APIConnectionError`, `APITimeoutError` | none | The API could not be reached |
|
|
153
|
+
|
|
154
|
+
## Retries and idempotency
|
|
155
|
+
|
|
156
|
+
The client retries connection failures, timeouts, `429 rate_limited` and `5xx`
|
|
157
|
+
up to `max_retries` times (default 2), with backoff, honouring `Retry-After`.
|
|
158
|
+
|
|
159
|
+
Every request that changes something carries an `Idempotency-Key`, and a retry
|
|
160
|
+
sends the *same* key and the *same* body, so the work is done once however many
|
|
161
|
+
times it is sent. To make your own retries safe across process restarts, pass
|
|
162
|
+
your own key:
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
client.documents.upload(file="bl.pdf", shipment_id=sid, idempotency_key=f"upload-{erp_document_id}")
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Uploading the same file to the same shipment twice returns the first document
|
|
169
|
+
(`duplicate=True`) and is not charged again.
|
|
170
|
+
|
|
171
|
+
## Usage and charges
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
usage = client.usage.get() # this month; or client.usage.get("2026-10")
|
|
175
|
+
for meter in usage.meters:
|
|
176
|
+
print(meter.name, meter.succeeded, "of", meter.quota or "unlimited")
|
|
177
|
+
print(usage.estimated_charge, usage.currency)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Requests are not charged. Usage is recorded when work completes: a document
|
|
181
|
+
accepted, a document processed, a verification completed. Sandbox usage is
|
|
182
|
+
measured and never charged. Charges are estimates until an invoice is issued.
|
|
183
|
+
|
|
184
|
+
## Configuration
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
ExportFlow(
|
|
188
|
+
api_key=None, # default: EXPORTFLOW_API_KEY
|
|
189
|
+
base_url=None, # default: EXPORTFLOW_BASE_URL or https://api.exportflow.app
|
|
190
|
+
timeout=60.0, # seconds per response
|
|
191
|
+
max_retries=2,
|
|
192
|
+
http_client=None, # your own httpx.Client: proxies, TLS, tests
|
|
193
|
+
)
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
The client is safe to share between threads. Use it as a context manager, or
|
|
197
|
+
call `close()`, to release its connections.
|
|
198
|
+
|
|
199
|
+
## Versioning
|
|
200
|
+
|
|
201
|
+
This SDK follows semantic versioning and speaks version 1 of the API. Within
|
|
202
|
+
version 1 the API only adds; nothing is removed or changed in meaning, so this
|
|
203
|
+
SDK keeps working as the API grows, and unknown fields are available on every
|
|
204
|
+
model's `.raw`. If the server ever reports a different major version the
|
|
205
|
+
client raises `IncompatibleAPIVersionError` rather than misread a response.
|
|
206
|
+
See `CHANGELOG.md`.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "exportflow"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "The official Python SDK for the ExportFlow document processing and verification API"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.9"
|
|
7
|
+
license = { text = "Proprietary" }
|
|
8
|
+
authors = [{ name = "MeerTech Ltd" }]
|
|
9
|
+
keywords = ["exportflow", "documents", "ocr", "trade", "verification", "erp"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Programming Language :: Python :: 3",
|
|
12
|
+
"Typing :: Typed",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
]
|
|
15
|
+
dependencies = ["httpx>=0.25,<1"]
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Documentation = "https://exportflow.app/developers"
|
|
19
|
+
Changelog = "https://exportflow.app/developers/changelog"
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["hatchling"]
|
|
23
|
+
build-backend = "hatchling.build"
|
|
24
|
+
|
|
25
|
+
[tool.hatch.build.targets.wheel]
|
|
26
|
+
packages = ["src/exportflow"]
|
|
27
|
+
|
|
28
|
+
[tool.pytest.ini_options]
|
|
29
|
+
testpaths = ["tests"]
|
|
30
|
+
pythonpath = ["src"]
|