ibanforge 1.1.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.
- ibanforge-1.1.0/.gitignore +18 -0
- ibanforge-1.1.0/PKG-INFO +197 -0
- ibanforge-1.1.0/README.md +159 -0
- ibanforge-1.1.0/ibanforge/__init__.py +46 -0
- ibanforge-1.1.0/ibanforge/async_client.py +174 -0
- ibanforge-1.1.0/ibanforge/client.py +228 -0
- ibanforge-1.1.0/ibanforge/exceptions.py +51 -0
- ibanforge-1.1.0/ibanforge/types.py +149 -0
- ibanforge-1.1.0/pyproject.toml +62 -0
- ibanforge-1.1.0/tests/__init__.py +0 -0
- ibanforge-1.1.0/tests/test_client.py +190 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
node_modules/
|
|
2
|
+
dist/
|
|
3
|
+
.env
|
|
4
|
+
*.log
|
|
5
|
+
.DS_Store
|
|
6
|
+
data/*.sqlite-wal
|
|
7
|
+
data/*.sqlite-shm
|
|
8
|
+
coverage/
|
|
9
|
+
*.tsbuildinfo
|
|
10
|
+
.superpowers/
|
|
11
|
+
# Append to .gitignore
|
|
12
|
+
mcp/node_modules/
|
|
13
|
+
mcp/dist/
|
|
14
|
+
|
|
15
|
+
# Secret tokens generated by mcp-publisher login
|
|
16
|
+
.mcpregistry_*_token
|
|
17
|
+
mcp/.mcpregistry_*_token
|
|
18
|
+
.vercel
|
ibanforge-1.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ibanforge
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Official Python SDK for the IBANforge API — IBAN validation, BIC/SWIFT lookup, Swiss BC-Nummer, sanctions/SEPA/VoP compliance triage.
|
|
5
|
+
Project-URL: Homepage, https://ibanforge.com
|
|
6
|
+
Project-URL: Documentation, https://ibanforge.com/docs
|
|
7
|
+
Project-URL: Agent Guide, https://ibanforge.com/agents
|
|
8
|
+
Project-URL: OpenAPI Reference, https://ibanforge.com/openapi
|
|
9
|
+
Project-URL: Repository, https://github.com/cammac-creator/ibanforge
|
|
10
|
+
Project-URL: Issue Tracker, https://github.com/cammac-creator/ibanforge/issues
|
|
11
|
+
Project-URL: Changelog, https://github.com/cammac-creator/ibanforge/blob/main/CHANGELOG.md
|
|
12
|
+
Author-email: IBANforge <support@ibanforge.com>
|
|
13
|
+
License-Expression: MIT
|
|
14
|
+
Keywords: ai-agent,api,banking,bic,compliance,fintech,iban,sanctions,sepa,swift,swiss,validation,vop,x402
|
|
15
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
18
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
19
|
+
Classifier: Operating System :: OS Independent
|
|
20
|
+
Classifier: Programming Language :: Python :: 3
|
|
21
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
26
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
27
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
28
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
29
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
30
|
+
Classifier: Typing :: Typed
|
|
31
|
+
Requires-Python: >=3.9
|
|
32
|
+
Requires-Dist: httpx>=0.24.0
|
|
33
|
+
Provides-Extra: test
|
|
34
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == 'test'
|
|
35
|
+
Requires-Dist: pytest>=7; extra == 'test'
|
|
36
|
+
Requires-Dist: respx>=0.20; extra == 'test'
|
|
37
|
+
Description-Content-Type: text/markdown
|
|
38
|
+
|
|
39
|
+
# IBANforge Python SDK
|
|
40
|
+
|
|
41
|
+
[](https://pypi.org/project/ibanforge/)
|
|
42
|
+
[](https://pypi.org/project/ibanforge/)
|
|
43
|
+
[](https://pypi.org/project/ibanforge/)
|
|
44
|
+
|
|
45
|
+
Official Python SDK for the [IBANforge API](https://ibanforge.com) — IBAN validation, BIC/SWIFT lookup, Swiss BC-Nummer, and sanctions/SEPA/VoP compliance triage.
|
|
46
|
+
|
|
47
|
+
Built for AI finance agents and fintech developers. Sync + async clients, full type hints, typed exceptions.
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install ibanforge
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Get a free API key (1 line, no signup form)
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from ibanforge import IBANforge
|
|
59
|
+
|
|
60
|
+
key = IBANforge.generate_api_key("you@example.com")
|
|
61
|
+
print(key["api_key"]) # ifk_… — store it, it's shown only once
|
|
62
|
+
print(key["monthly_limit"]) # 200 free requests/month
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
When the monthly quota is exhausted, the API automatically falls back to advertising x402 payment requirements (no dead-end). Your key resumes working at the start of the next month.
|
|
66
|
+
|
|
67
|
+
## Quick start (sync)
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from ibanforge import IBANforge
|
|
71
|
+
|
|
72
|
+
with IBANforge(api_key="ifk_...") as client:
|
|
73
|
+
out = client.validate_iban("CH9300762011623852957")
|
|
74
|
+
|
|
75
|
+
print(out["valid"]) # True
|
|
76
|
+
print(out["country"]) # {"code": "CH", "name": "Switzerland"}
|
|
77
|
+
print(out["bic"]["bankName"]) # "UBS Switzerland AG"
|
|
78
|
+
print(out["bic"]["lei"]) # "BFM8T61CT2L1QCEMIK50"
|
|
79
|
+
print(out["sepa"]) # {"reachable": True, "instant": True}
|
|
80
|
+
print(out["vop"]["participant"]) # True
|
|
81
|
+
print(out["ch_clearing"]["bc_nummer"]) # "762"
|
|
82
|
+
print(out["risk_score"]) # 5
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Quick start (async)
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
import asyncio
|
|
89
|
+
from ibanforge import AsyncIBANforge
|
|
90
|
+
|
|
91
|
+
async def main():
|
|
92
|
+
async with AsyncIBANforge(api_key="ifk_...") as ibanforge:
|
|
93
|
+
# Fan out 100 validations concurrently
|
|
94
|
+
results = await asyncio.gather(*[
|
|
95
|
+
ibanforge.validate_iban(iban) for iban in ibans
|
|
96
|
+
])
|
|
97
|
+
valid = sum(1 for r in results if r["valid"])
|
|
98
|
+
print(f"{valid}/{len(results)} valid")
|
|
99
|
+
|
|
100
|
+
asyncio.run(main())
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## All endpoints
|
|
104
|
+
|
|
105
|
+
| Method | Cost | What it does |
|
|
106
|
+
|---|---|---|
|
|
107
|
+
| `format_iban(iban)` | **free** | Pure mod-97 + structure check. Use to pre-filter malformed IBANs before paying. |
|
|
108
|
+
| `validate_iban(iban)` | $0.005 | Full enrichment — BIC, country, EMI/vIBAN flag, SEPA + VoP, risk score, Swiss BC-Nummer for CH/LI |
|
|
109
|
+
| `validate_batch([iban, ...])` | $0.002 / IBAN | Up to 100 IBANs in one call. CSV cleanup, payout list triage. |
|
|
110
|
+
| `lookup_bic(code)` | $0.003 | Resolve BIC/SWIFT into bank name, country, city, LEI, address. 121,197 GLEIF entries. |
|
|
111
|
+
| `lookup_ch_clearing(iid)` | $0.003 | Resolve a Swiss BC-Nummer / IID. The only API with this data. |
|
|
112
|
+
| `check_compliance(iban)` | $0.02 | Pre-flight risk triage: sanctions (OFAC/EU/UN), FATF, SEPA Instant, VoP, risk score 0-100, recommended_action ∈ {allow, review, block} |
|
|
113
|
+
| `usage()` | free | Current month quota usage for your key |
|
|
114
|
+
| `health()` | free | API version, BIC count, uptime |
|
|
115
|
+
|
|
116
|
+
## Free format check (no key needed)
|
|
117
|
+
|
|
118
|
+
Save money by pre-filtering bad IBANs before paying for enrichment:
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
from ibanforge import IBANforge
|
|
122
|
+
|
|
123
|
+
with IBANforge() as client: # no api_key required
|
|
124
|
+
out = client.format_iban("CH9300762011623852957")
|
|
125
|
+
if not out["valid"]:
|
|
126
|
+
print("Skip:", out["error"]) # e.g. "checksum_failed"
|
|
127
|
+
else:
|
|
128
|
+
print(out["bban"]["bank_code"]) # "00762"
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Compliance triage (the killer feature)
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
out = client.check_compliance("RU1234567890123456789012345678") # made-up RU
|
|
135
|
+
print(out["risk_score"]) # high (Russia + FATF flag)
|
|
136
|
+
print(out["recommended_action"]) # "block" | "review" | "allow"
|
|
137
|
+
print(out["sanctions"]["lists"]) # ["OFAC", "EU"] etc.
|
|
138
|
+
print(out["fatf"]["list"]) # "grey" | "black" | "none"
|
|
139
|
+
print(out["sepa"]["reachable"])
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Error handling
|
|
143
|
+
|
|
144
|
+
The SDK raises typed exceptions — catch the specific class you care about, or the base `IBANforgeError`:
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
from ibanforge import (
|
|
148
|
+
IBANforge,
|
|
149
|
+
AuthError, PaymentRequiredError, QuotaExhaustedError,
|
|
150
|
+
RateLimitError, InvalidInputError, APIError, IBANforgeError,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
with IBANforge(api_key="ifk_...") as client:
|
|
154
|
+
try:
|
|
155
|
+
out = client.validate_iban("not-an-iban")
|
|
156
|
+
except InvalidInputError as e:
|
|
157
|
+
print(e.body["error"]) # "invalid_format"
|
|
158
|
+
except AuthError:
|
|
159
|
+
print("Bad or revoked API key")
|
|
160
|
+
except PaymentRequiredError as e:
|
|
161
|
+
# Your quota is exhausted — switch to x402 to keep going
|
|
162
|
+
print(e.body["accepts"]) # x402 payment requirements
|
|
163
|
+
except RateLimitError:
|
|
164
|
+
print("Slow down")
|
|
165
|
+
except APIError as e:
|
|
166
|
+
print(f"Server error {e.status} — retry with backoff")
|
|
167
|
+
except IBANforgeError as e:
|
|
168
|
+
print(f"Other: {e}")
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## For LLM agents (LangChain, LlamaIndex, CrewAI, AutoGen)
|
|
172
|
+
|
|
173
|
+
The IBANforge API is also available as a native MCP server (`npx -y ibanforge-mcp`) and via x402 micropayments — see the [agent guide](https://ibanforge.com/agents). For Python-first agents, the SDK above is usually enough.
|
|
174
|
+
|
|
175
|
+
## Configuration
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
client = IBANforge(
|
|
179
|
+
api_key="ifk_...",
|
|
180
|
+
base_url="https://api.ibanforge.com", # default
|
|
181
|
+
timeout=30.0, # seconds, default
|
|
182
|
+
user_agent="my-app/1.2", # custom UA
|
|
183
|
+
)
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## Links
|
|
187
|
+
|
|
188
|
+
- API documentation: <https://ibanforge.com/docs>
|
|
189
|
+
- Interactive OpenAPI: <https://ibanforge.com/openapi>
|
|
190
|
+
- Agent guide: <https://ibanforge.com/agents>
|
|
191
|
+
- TypeScript SDK: [`@ibanforge/sdk`](https://www.npmjs.com/package/@ibanforge/sdk)
|
|
192
|
+
- MCP server: [`ibanforge-mcp`](https://www.npmjs.com/package/ibanforge-mcp)
|
|
193
|
+
- Source: <https://github.com/cammac-creator/ibanforge>
|
|
194
|
+
|
|
195
|
+
## License
|
|
196
|
+
|
|
197
|
+
MIT.
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# IBANforge Python SDK
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/ibanforge/)
|
|
4
|
+
[](https://pypi.org/project/ibanforge/)
|
|
5
|
+
[](https://pypi.org/project/ibanforge/)
|
|
6
|
+
|
|
7
|
+
Official Python SDK for the [IBANforge API](https://ibanforge.com) — IBAN validation, BIC/SWIFT lookup, Swiss BC-Nummer, and sanctions/SEPA/VoP compliance triage.
|
|
8
|
+
|
|
9
|
+
Built for AI finance agents and fintech developers. Sync + async clients, full type hints, typed exceptions.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install ibanforge
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Get a free API key (1 line, no signup form)
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from ibanforge import IBANforge
|
|
21
|
+
|
|
22
|
+
key = IBANforge.generate_api_key("you@example.com")
|
|
23
|
+
print(key["api_key"]) # ifk_… — store it, it's shown only once
|
|
24
|
+
print(key["monthly_limit"]) # 200 free requests/month
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
When the monthly quota is exhausted, the API automatically falls back to advertising x402 payment requirements (no dead-end). Your key resumes working at the start of the next month.
|
|
28
|
+
|
|
29
|
+
## Quick start (sync)
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from ibanforge import IBANforge
|
|
33
|
+
|
|
34
|
+
with IBANforge(api_key="ifk_...") as client:
|
|
35
|
+
out = client.validate_iban("CH9300762011623852957")
|
|
36
|
+
|
|
37
|
+
print(out["valid"]) # True
|
|
38
|
+
print(out["country"]) # {"code": "CH", "name": "Switzerland"}
|
|
39
|
+
print(out["bic"]["bankName"]) # "UBS Switzerland AG"
|
|
40
|
+
print(out["bic"]["lei"]) # "BFM8T61CT2L1QCEMIK50"
|
|
41
|
+
print(out["sepa"]) # {"reachable": True, "instant": True}
|
|
42
|
+
print(out["vop"]["participant"]) # True
|
|
43
|
+
print(out["ch_clearing"]["bc_nummer"]) # "762"
|
|
44
|
+
print(out["risk_score"]) # 5
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Quick start (async)
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
import asyncio
|
|
51
|
+
from ibanforge import AsyncIBANforge
|
|
52
|
+
|
|
53
|
+
async def main():
|
|
54
|
+
async with AsyncIBANforge(api_key="ifk_...") as ibanforge:
|
|
55
|
+
# Fan out 100 validations concurrently
|
|
56
|
+
results = await asyncio.gather(*[
|
|
57
|
+
ibanforge.validate_iban(iban) for iban in ibans
|
|
58
|
+
])
|
|
59
|
+
valid = sum(1 for r in results if r["valid"])
|
|
60
|
+
print(f"{valid}/{len(results)} valid")
|
|
61
|
+
|
|
62
|
+
asyncio.run(main())
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## All endpoints
|
|
66
|
+
|
|
67
|
+
| Method | Cost | What it does |
|
|
68
|
+
|---|---|---|
|
|
69
|
+
| `format_iban(iban)` | **free** | Pure mod-97 + structure check. Use to pre-filter malformed IBANs before paying. |
|
|
70
|
+
| `validate_iban(iban)` | $0.005 | Full enrichment — BIC, country, EMI/vIBAN flag, SEPA + VoP, risk score, Swiss BC-Nummer for CH/LI |
|
|
71
|
+
| `validate_batch([iban, ...])` | $0.002 / IBAN | Up to 100 IBANs in one call. CSV cleanup, payout list triage. |
|
|
72
|
+
| `lookup_bic(code)` | $0.003 | Resolve BIC/SWIFT into bank name, country, city, LEI, address. 121,197 GLEIF entries. |
|
|
73
|
+
| `lookup_ch_clearing(iid)` | $0.003 | Resolve a Swiss BC-Nummer / IID. The only API with this data. |
|
|
74
|
+
| `check_compliance(iban)` | $0.02 | Pre-flight risk triage: sanctions (OFAC/EU/UN), FATF, SEPA Instant, VoP, risk score 0-100, recommended_action ∈ {allow, review, block} |
|
|
75
|
+
| `usage()` | free | Current month quota usage for your key |
|
|
76
|
+
| `health()` | free | API version, BIC count, uptime |
|
|
77
|
+
|
|
78
|
+
## Free format check (no key needed)
|
|
79
|
+
|
|
80
|
+
Save money by pre-filtering bad IBANs before paying for enrichment:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from ibanforge import IBANforge
|
|
84
|
+
|
|
85
|
+
with IBANforge() as client: # no api_key required
|
|
86
|
+
out = client.format_iban("CH9300762011623852957")
|
|
87
|
+
if not out["valid"]:
|
|
88
|
+
print("Skip:", out["error"]) # e.g. "checksum_failed"
|
|
89
|
+
else:
|
|
90
|
+
print(out["bban"]["bank_code"]) # "00762"
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Compliance triage (the killer feature)
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
out = client.check_compliance("RU1234567890123456789012345678") # made-up RU
|
|
97
|
+
print(out["risk_score"]) # high (Russia + FATF flag)
|
|
98
|
+
print(out["recommended_action"]) # "block" | "review" | "allow"
|
|
99
|
+
print(out["sanctions"]["lists"]) # ["OFAC", "EU"] etc.
|
|
100
|
+
print(out["fatf"]["list"]) # "grey" | "black" | "none"
|
|
101
|
+
print(out["sepa"]["reachable"])
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Error handling
|
|
105
|
+
|
|
106
|
+
The SDK raises typed exceptions — catch the specific class you care about, or the base `IBANforgeError`:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from ibanforge import (
|
|
110
|
+
IBANforge,
|
|
111
|
+
AuthError, PaymentRequiredError, QuotaExhaustedError,
|
|
112
|
+
RateLimitError, InvalidInputError, APIError, IBANforgeError,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
with IBANforge(api_key="ifk_...") as client:
|
|
116
|
+
try:
|
|
117
|
+
out = client.validate_iban("not-an-iban")
|
|
118
|
+
except InvalidInputError as e:
|
|
119
|
+
print(e.body["error"]) # "invalid_format"
|
|
120
|
+
except AuthError:
|
|
121
|
+
print("Bad or revoked API key")
|
|
122
|
+
except PaymentRequiredError as e:
|
|
123
|
+
# Your quota is exhausted — switch to x402 to keep going
|
|
124
|
+
print(e.body["accepts"]) # x402 payment requirements
|
|
125
|
+
except RateLimitError:
|
|
126
|
+
print("Slow down")
|
|
127
|
+
except APIError as e:
|
|
128
|
+
print(f"Server error {e.status} — retry with backoff")
|
|
129
|
+
except IBANforgeError as e:
|
|
130
|
+
print(f"Other: {e}")
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## For LLM agents (LangChain, LlamaIndex, CrewAI, AutoGen)
|
|
134
|
+
|
|
135
|
+
The IBANforge API is also available as a native MCP server (`npx -y ibanforge-mcp`) and via x402 micropayments — see the [agent guide](https://ibanforge.com/agents). For Python-first agents, the SDK above is usually enough.
|
|
136
|
+
|
|
137
|
+
## Configuration
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
client = IBANforge(
|
|
141
|
+
api_key="ifk_...",
|
|
142
|
+
base_url="https://api.ibanforge.com", # default
|
|
143
|
+
timeout=30.0, # seconds, default
|
|
144
|
+
user_agent="my-app/1.2", # custom UA
|
|
145
|
+
)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Links
|
|
149
|
+
|
|
150
|
+
- API documentation: <https://ibanforge.com/docs>
|
|
151
|
+
- Interactive OpenAPI: <https://ibanforge.com/openapi>
|
|
152
|
+
- Agent guide: <https://ibanforge.com/agents>
|
|
153
|
+
- TypeScript SDK: [`@ibanforge/sdk`](https://www.npmjs.com/package/@ibanforge/sdk)
|
|
154
|
+
- MCP server: [`ibanforge-mcp`](https://www.npmjs.com/package/ibanforge-mcp)
|
|
155
|
+
- Source: <https://github.com/cammac-creator/ibanforge>
|
|
156
|
+
|
|
157
|
+
## License
|
|
158
|
+
|
|
159
|
+
MIT.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""IBANforge Python SDK.
|
|
2
|
+
|
|
3
|
+
Official client for the IBANforge REST API — IBAN validation, BIC/SWIFT
|
|
4
|
+
lookup, Swiss BC-Nummer, compliance triage. Made for AI finance agents and
|
|
5
|
+
fintech developers.
|
|
6
|
+
|
|
7
|
+
Quick start:
|
|
8
|
+
|
|
9
|
+
pip install ibanforge
|
|
10
|
+
|
|
11
|
+
from ibanforge import IBANforge
|
|
12
|
+
client = IBANforge(api_key="ifk_...")
|
|
13
|
+
out = client.validate_iban("CH9300762011623852957")
|
|
14
|
+
print(out["valid"], out.get("bic", {}).get("bankName"))
|
|
15
|
+
|
|
16
|
+
For asyncio (FastAPI, langchain async, fan-out concurrency):
|
|
17
|
+
|
|
18
|
+
from ibanforge import AsyncIBANforge
|
|
19
|
+
async with AsyncIBANforge(api_key="ifk_...") as client:
|
|
20
|
+
out = await client.validate_iban("...")
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from .async_client import AsyncIBANforge
|
|
24
|
+
from .client import IBANforge
|
|
25
|
+
from .exceptions import (
|
|
26
|
+
APIError,
|
|
27
|
+
AuthError,
|
|
28
|
+
IBANforgeError,
|
|
29
|
+
InvalidInputError,
|
|
30
|
+
PaymentRequiredError,
|
|
31
|
+
QuotaExhaustedError,
|
|
32
|
+
RateLimitError,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"IBANforge",
|
|
37
|
+
"AsyncIBANforge",
|
|
38
|
+
"IBANforgeError",
|
|
39
|
+
"AuthError",
|
|
40
|
+
"PaymentRequiredError",
|
|
41
|
+
"QuotaExhaustedError",
|
|
42
|
+
"RateLimitError",
|
|
43
|
+
"InvalidInputError",
|
|
44
|
+
"APIError",
|
|
45
|
+
]
|
|
46
|
+
__version__ = "1.1.0"
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Asynchronous IBANforge API client (asyncio-friendly).
|
|
2
|
+
|
|
3
|
+
Mirrors the sync `IBANforge` API one-for-one but returns awaitables and uses
|
|
4
|
+
`httpx.AsyncClient` under the hood — pick this when you're calling the API
|
|
5
|
+
from inside an async framework (FastAPI, aiohttp, langchain async tools, etc.)
|
|
6
|
+
or when you need to fan-out 100s of validations concurrently.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from ibanforge import AsyncIBANforge
|
|
12
|
+
|
|
13
|
+
async def main():
|
|
14
|
+
async with AsyncIBANforge(api_key="ifk_...") as ibanforge:
|
|
15
|
+
out = await ibanforge.validate_iban("CH9300762011623852957")
|
|
16
|
+
print(out["valid"])
|
|
17
|
+
|
|
18
|
+
asyncio.run(main())
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from typing import Any, Iterable, Optional, Union
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
|
|
27
|
+
from .exceptions import (
|
|
28
|
+
APIError,
|
|
29
|
+
AuthError,
|
|
30
|
+
IBANforgeError,
|
|
31
|
+
InvalidInputError,
|
|
32
|
+
PaymentRequiredError,
|
|
33
|
+
QuotaExhaustedError,
|
|
34
|
+
RateLimitError,
|
|
35
|
+
)
|
|
36
|
+
from .types import (
|
|
37
|
+
APIKey,
|
|
38
|
+
APIKeyUsage,
|
|
39
|
+
BICLookupResult,
|
|
40
|
+
CHClearingResult,
|
|
41
|
+
ComplianceResult,
|
|
42
|
+
HealthInfo,
|
|
43
|
+
IBANBatchResult,
|
|
44
|
+
IBANFormatResult,
|
|
45
|
+
IBANValidationResult,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
DEFAULT_BASE_URL = "https://api.ibanforge.com"
|
|
49
|
+
DEFAULT_TIMEOUT = 30.0
|
|
50
|
+
USER_AGENT = "ibanforge-python/1.1.0"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _raise_for_status(res: httpx.Response) -> None:
|
|
54
|
+
if res.is_success:
|
|
55
|
+
return
|
|
56
|
+
body: Any
|
|
57
|
+
try:
|
|
58
|
+
body = res.json()
|
|
59
|
+
except Exception:
|
|
60
|
+
body = res.text
|
|
61
|
+
msg_obj = body if isinstance(body, dict) else {}
|
|
62
|
+
msg = (
|
|
63
|
+
msg_obj.get("message")
|
|
64
|
+
or msg_obj.get("error_detail")
|
|
65
|
+
or msg_obj.get("error")
|
|
66
|
+
or res.reason_phrase
|
|
67
|
+
or "Unknown error"
|
|
68
|
+
)
|
|
69
|
+
if res.status_code == 401:
|
|
70
|
+
raise AuthError(msg, status=401, body=body)
|
|
71
|
+
if res.status_code == 402:
|
|
72
|
+
raise PaymentRequiredError(msg, status=402, body=body)
|
|
73
|
+
if res.status_code == 403:
|
|
74
|
+
raise AuthError(msg, status=403, body=body)
|
|
75
|
+
if res.status_code == 429:
|
|
76
|
+
if msg_obj.get("error") == "quota_exceeded":
|
|
77
|
+
raise QuotaExhaustedError(msg, status=429, body=body)
|
|
78
|
+
raise RateLimitError(msg, status=429, body=body)
|
|
79
|
+
if 400 <= res.status_code < 500:
|
|
80
|
+
raise InvalidInputError(msg, status=res.status_code, body=body)
|
|
81
|
+
if res.status_code >= 500:
|
|
82
|
+
raise APIError(msg, status=res.status_code, body=body)
|
|
83
|
+
raise IBANforgeError(msg, status=res.status_code, body=body)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class AsyncIBANforge:
|
|
87
|
+
"""Async client for the IBANforge REST API."""
|
|
88
|
+
|
|
89
|
+
def __init__(
|
|
90
|
+
self,
|
|
91
|
+
api_key: Optional[str] = None,
|
|
92
|
+
*,
|
|
93
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
94
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
95
|
+
user_agent: str = USER_AGENT,
|
|
96
|
+
) -> None:
|
|
97
|
+
self.base_url = base_url.rstrip("/")
|
|
98
|
+
self.api_key = api_key
|
|
99
|
+
headers = {"User-Agent": user_agent}
|
|
100
|
+
if api_key:
|
|
101
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
102
|
+
self._client = httpx.AsyncClient(
|
|
103
|
+
base_url=self.base_url, timeout=timeout, headers=headers
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
async def __aenter__(self) -> "AsyncIBANforge":
|
|
107
|
+
return self
|
|
108
|
+
|
|
109
|
+
async def __aexit__(self, *_: object) -> None:
|
|
110
|
+
await self.aclose()
|
|
111
|
+
|
|
112
|
+
async def aclose(self) -> None:
|
|
113
|
+
await self._client.aclose()
|
|
114
|
+
|
|
115
|
+
async def format_iban(self, iban: str) -> IBANFormatResult:
|
|
116
|
+
res = await self._client.get("/v1/iban/format", params={"iban": iban})
|
|
117
|
+
_raise_for_status(res)
|
|
118
|
+
return res.json()
|
|
119
|
+
|
|
120
|
+
async def validate_iban(self, iban: str) -> IBANValidationResult:
|
|
121
|
+
res = await self._client.post("/v1/iban/validate", json={"iban": iban})
|
|
122
|
+
_raise_for_status(res)
|
|
123
|
+
return res.json()
|
|
124
|
+
|
|
125
|
+
async def validate_batch(self, ibans: Iterable[str]) -> IBANBatchResult:
|
|
126
|
+
ibans_list = list(ibans)
|
|
127
|
+
if not ibans_list:
|
|
128
|
+
raise InvalidInputError("ibans must contain at least one IBAN")
|
|
129
|
+
if len(ibans_list) > 100:
|
|
130
|
+
raise InvalidInputError(
|
|
131
|
+
"ibans must be at most 100 entries (got {})".format(len(ibans_list))
|
|
132
|
+
)
|
|
133
|
+
res = await self._client.post("/v1/iban/batch", json={"ibans": ibans_list})
|
|
134
|
+
_raise_for_status(res)
|
|
135
|
+
return res.json()
|
|
136
|
+
|
|
137
|
+
async def check_compliance(self, iban: str) -> ComplianceResult:
|
|
138
|
+
res = await self._client.post("/v1/iban/compliance", json={"iban": iban})
|
|
139
|
+
_raise_for_status(res)
|
|
140
|
+
return res.json()
|
|
141
|
+
|
|
142
|
+
async def lookup_bic(self, code: str) -> BICLookupResult:
|
|
143
|
+
res = await self._client.get(f"/v1/bic/{code}")
|
|
144
|
+
_raise_for_status(res)
|
|
145
|
+
return res.json()
|
|
146
|
+
|
|
147
|
+
async def lookup_ch_clearing(self, iid: Union[str, int]) -> CHClearingResult:
|
|
148
|
+
res = await self._client.get(f"/v1/ch/clearing/{iid}")
|
|
149
|
+
_raise_for_status(res)
|
|
150
|
+
return res.json()
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
async def generate_api_key(
|
|
154
|
+
email: str,
|
|
155
|
+
*,
|
|
156
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
157
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
158
|
+
) -> APIKey:
|
|
159
|
+
async with httpx.AsyncClient(base_url=base_url.rstrip("/"), timeout=timeout) as cl:
|
|
160
|
+
res = await cl.post("/v1/keys/generate", json={"email": email})
|
|
161
|
+
_raise_for_status(res)
|
|
162
|
+
return res.json()
|
|
163
|
+
|
|
164
|
+
async def usage(self) -> APIKeyUsage:
|
|
165
|
+
if not self.api_key:
|
|
166
|
+
raise AuthError("usage() requires an API key — pass api_key='ifk_...' to the constructor")
|
|
167
|
+
res = await self._client.get("/v1/keys/usage")
|
|
168
|
+
_raise_for_status(res)
|
|
169
|
+
return res.json()
|
|
170
|
+
|
|
171
|
+
async def health(self) -> HealthInfo:
|
|
172
|
+
res = await self._client.get("/health")
|
|
173
|
+
_raise_for_status(res)
|
|
174
|
+
return res.json()
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Synchronous IBANforge API client.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
|
|
5
|
+
from ibanforge import IBANforge
|
|
6
|
+
|
|
7
|
+
# Free format check (no key needed)
|
|
8
|
+
client = IBANforge()
|
|
9
|
+
out = client.format_iban("CH9300762011623852957")
|
|
10
|
+
|
|
11
|
+
# Authenticated calls (required for paid endpoints unless you go x402)
|
|
12
|
+
client = IBANforge(api_key="ifk_...")
|
|
13
|
+
out = client.validate_iban("CH9300762011623852957")
|
|
14
|
+
|
|
15
|
+
# Generate a free key in 1 line
|
|
16
|
+
key = IBANforge.generate_api_key("you@example.com")
|
|
17
|
+
client = IBANforge(api_key=key["api_key"])
|
|
18
|
+
|
|
19
|
+
The client raises typed exceptions from `ibanforge.exceptions` — catch the
|
|
20
|
+
specific class you care about (PaymentRequiredError, QuotaExhaustedError,
|
|
21
|
+
InvalidInputError, AuthError, RateLimitError, APIError) or the base
|
|
22
|
+
IBANforgeError to catch them all.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from typing import Any, Iterable, Optional, Union
|
|
28
|
+
|
|
29
|
+
import httpx
|
|
30
|
+
|
|
31
|
+
from .exceptions import (
|
|
32
|
+
APIError,
|
|
33
|
+
AuthError,
|
|
34
|
+
IBANforgeError,
|
|
35
|
+
InvalidInputError,
|
|
36
|
+
PaymentRequiredError,
|
|
37
|
+
QuotaExhaustedError,
|
|
38
|
+
RateLimitError,
|
|
39
|
+
)
|
|
40
|
+
from .types import (
|
|
41
|
+
APIKey,
|
|
42
|
+
APIKeyUsage,
|
|
43
|
+
BICLookupResult,
|
|
44
|
+
CHClearingResult,
|
|
45
|
+
ComplianceResult,
|
|
46
|
+
HealthInfo,
|
|
47
|
+
IBANBatchResult,
|
|
48
|
+
IBANFormatResult,
|
|
49
|
+
IBANValidationResult,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
DEFAULT_BASE_URL = "https://api.ibanforge.com"
|
|
53
|
+
DEFAULT_TIMEOUT = 30.0
|
|
54
|
+
USER_AGENT = "ibanforge-python/1.1.0"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _raise_for_status(res: httpx.Response) -> None:
|
|
58
|
+
"""Map HTTP status codes to typed exceptions."""
|
|
59
|
+
if res.is_success:
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
body: Any
|
|
63
|
+
try:
|
|
64
|
+
body = res.json()
|
|
65
|
+
except Exception:
|
|
66
|
+
body = res.text
|
|
67
|
+
|
|
68
|
+
msg_obj = body if isinstance(body, dict) else {}
|
|
69
|
+
msg = (
|
|
70
|
+
msg_obj.get("message")
|
|
71
|
+
or msg_obj.get("error_detail")
|
|
72
|
+
or msg_obj.get("error")
|
|
73
|
+
or res.reason_phrase
|
|
74
|
+
or "Unknown error"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
if res.status_code == 401:
|
|
78
|
+
raise AuthError(msg, status=401, body=body)
|
|
79
|
+
if res.status_code == 402:
|
|
80
|
+
raise PaymentRequiredError(msg, status=402, body=body)
|
|
81
|
+
if res.status_code == 403:
|
|
82
|
+
raise AuthError(msg, status=403, body=body)
|
|
83
|
+
if res.status_code == 429:
|
|
84
|
+
# IBANforge usually falls through to 402 instead of 429 when an api-key's
|
|
85
|
+
# quota is exhausted, but we still distinguish here.
|
|
86
|
+
if msg_obj.get("error") == "quota_exceeded":
|
|
87
|
+
raise QuotaExhaustedError(msg, status=429, body=body)
|
|
88
|
+
raise RateLimitError(msg, status=429, body=body)
|
|
89
|
+
if 400 <= res.status_code < 500:
|
|
90
|
+
raise InvalidInputError(msg, status=res.status_code, body=body)
|
|
91
|
+
if res.status_code >= 500:
|
|
92
|
+
raise APIError(msg, status=res.status_code, body=body)
|
|
93
|
+
raise IBANforgeError(msg, status=res.status_code, body=body)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class IBANforge:
|
|
97
|
+
"""Synchronous client for the IBANforge REST API."""
|
|
98
|
+
|
|
99
|
+
def __init__(
|
|
100
|
+
self,
|
|
101
|
+
api_key: Optional[str] = None,
|
|
102
|
+
*,
|
|
103
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
104
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
105
|
+
user_agent: str = USER_AGENT,
|
|
106
|
+
) -> None:
|
|
107
|
+
self.base_url = base_url.rstrip("/")
|
|
108
|
+
self.api_key = api_key
|
|
109
|
+
headers = {"User-Agent": user_agent}
|
|
110
|
+
if api_key:
|
|
111
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
112
|
+
self._client = httpx.Client(base_url=self.base_url, timeout=timeout, headers=headers)
|
|
113
|
+
|
|
114
|
+
# ---- context manager ----
|
|
115
|
+
|
|
116
|
+
def __enter__(self) -> "IBANforge":
|
|
117
|
+
return self
|
|
118
|
+
|
|
119
|
+
def __exit__(self, *_: object) -> None:
|
|
120
|
+
self.close()
|
|
121
|
+
|
|
122
|
+
def close(self) -> None:
|
|
123
|
+
self._client.close()
|
|
124
|
+
|
|
125
|
+
# ---- IBAN ----
|
|
126
|
+
|
|
127
|
+
def format_iban(self, iban: str) -> IBANFormatResult:
|
|
128
|
+
"""FREE pre-flight check (mod-97 + structure). No API key required.
|
|
129
|
+
|
|
130
|
+
Use this to filter malformed IBANs before paying for full enrichment.
|
|
131
|
+
Returns valid/invalid + error code + bban breakdown only — no BIC,
|
|
132
|
+
no SEPA, no compliance data.
|
|
133
|
+
"""
|
|
134
|
+
res = self._client.get("/v1/iban/format", params={"iban": iban})
|
|
135
|
+
_raise_for_status(res)
|
|
136
|
+
return res.json()
|
|
137
|
+
|
|
138
|
+
def validate_iban(self, iban: str) -> IBANValidationResult:
|
|
139
|
+
"""Validate one IBAN with full enrichment ($0.005 / call with API key).
|
|
140
|
+
|
|
141
|
+
Returns BIC, country, EMI/vIBAN classification, SEPA + VoP flags,
|
|
142
|
+
risk score, Swiss BC-Nummer for CH/LI accounts.
|
|
143
|
+
"""
|
|
144
|
+
res = self._client.post("/v1/iban/validate", json={"iban": iban})
|
|
145
|
+
_raise_for_status(res)
|
|
146
|
+
return res.json()
|
|
147
|
+
|
|
148
|
+
def validate_batch(self, ibans: Iterable[str]) -> IBANBatchResult:
|
|
149
|
+
"""Validate up to 100 IBANs in one call ($0.002 / IBAN with API key)."""
|
|
150
|
+
ibans_list = list(ibans)
|
|
151
|
+
if not ibans_list:
|
|
152
|
+
raise InvalidInputError("ibans must contain at least one IBAN")
|
|
153
|
+
if len(ibans_list) > 100:
|
|
154
|
+
raise InvalidInputError(
|
|
155
|
+
"ibans must be at most 100 entries (got {})".format(len(ibans_list))
|
|
156
|
+
)
|
|
157
|
+
res = self._client.post("/v1/iban/batch", json={"ibans": ibans_list})
|
|
158
|
+
_raise_for_status(res)
|
|
159
|
+
return res.json()
|
|
160
|
+
|
|
161
|
+
def check_compliance(self, iban: str) -> ComplianceResult:
|
|
162
|
+
"""Pre-flight compliance triage on an IBAN ($0.02 / call with API key).
|
|
163
|
+
|
|
164
|
+
Returns sanctions screening (OFAC/EU/UN), FATF jurisdiction flag,
|
|
165
|
+
SEPA Instant reachability, VoP participant status, risk score 0-100,
|
|
166
|
+
and a recommended_action of "allow" | "review" | "block".
|
|
167
|
+
|
|
168
|
+
Informational, not a regulated AML/CFT product.
|
|
169
|
+
"""
|
|
170
|
+
res = self._client.post("/v1/iban/compliance", json={"iban": iban})
|
|
171
|
+
_raise_for_status(res)
|
|
172
|
+
return res.json()
|
|
173
|
+
|
|
174
|
+
# ---- BIC / SWIFT ----
|
|
175
|
+
|
|
176
|
+
def lookup_bic(self, code: str) -> BICLookupResult:
|
|
177
|
+
"""Resolve a BIC/SWIFT code into bank, country, city, LEI ($0.003 / call)."""
|
|
178
|
+
res = self._client.get(f"/v1/bic/{code}")
|
|
179
|
+
_raise_for_status(res)
|
|
180
|
+
return res.json()
|
|
181
|
+
|
|
182
|
+
# ---- Swiss clearing ----
|
|
183
|
+
|
|
184
|
+
def lookup_ch_clearing(self, iid: Union[str, int]) -> CHClearingResult:
|
|
185
|
+
"""Resolve a Swiss BC-Nummer / IID into institution data ($0.003 / call).
|
|
186
|
+
|
|
187
|
+
Backed by 1,190 SIX BankMaster entries — the only public API exposing
|
|
188
|
+
this data.
|
|
189
|
+
"""
|
|
190
|
+
res = self._client.get(f"/v1/ch/clearing/{iid}")
|
|
191
|
+
_raise_for_status(res)
|
|
192
|
+
return res.json()
|
|
193
|
+
|
|
194
|
+
# ---- API keys ----
|
|
195
|
+
|
|
196
|
+
@staticmethod
|
|
197
|
+
def generate_api_key(
|
|
198
|
+
email: str,
|
|
199
|
+
*,
|
|
200
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
201
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
202
|
+
) -> APIKey:
|
|
203
|
+
"""Create a free API key (200 requests/month).
|
|
204
|
+
|
|
205
|
+
The key is shown ONCE — store it securely. After the monthly quota the
|
|
206
|
+
IBANforge API falls back to advertising x402 payment requirements; the
|
|
207
|
+
same key continues to work next month.
|
|
208
|
+
"""
|
|
209
|
+
with httpx.Client(base_url=base_url.rstrip("/"), timeout=timeout) as cl:
|
|
210
|
+
res = cl.post("/v1/keys/generate", json={"email": email})
|
|
211
|
+
_raise_for_status(res)
|
|
212
|
+
return res.json()
|
|
213
|
+
|
|
214
|
+
def usage(self) -> APIKeyUsage:
|
|
215
|
+
"""Get the current month's quota usage for the configured API key."""
|
|
216
|
+
if not self.api_key:
|
|
217
|
+
raise AuthError("usage() requires an API key — pass api_key='ifk_...' to the constructor")
|
|
218
|
+
res = self._client.get("/v1/keys/usage")
|
|
219
|
+
_raise_for_status(res)
|
|
220
|
+
return res.json()
|
|
221
|
+
|
|
222
|
+
# ---- Misc ----
|
|
223
|
+
|
|
224
|
+
def health(self) -> HealthInfo:
|
|
225
|
+
"""Public health endpoint — version, BIC count, uptime, basic stats."""
|
|
226
|
+
res = self._client.get("/health")
|
|
227
|
+
_raise_for_status(res)
|
|
228
|
+
return res.json()
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Custom exception classes for the IBANforge SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class IBANforgeError(Exception):
|
|
9
|
+
"""Base class for all IBANforge SDK errors."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, message: str, *, status: int | None = None, body: Any | None = None) -> None:
|
|
12
|
+
super().__init__(message)
|
|
13
|
+
self.status = status
|
|
14
|
+
self.body = body
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AuthError(IBANforgeError):
|
|
18
|
+
"""API key is missing, invalid, or revoked."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PaymentRequiredError(IBANforgeError):
|
|
22
|
+
"""402 returned by the API. The agent must either authenticate with an API key
|
|
23
|
+
(Authorization: Bearer ifk_...) or pay per call via the x402 protocol on Base.
|
|
24
|
+
|
|
25
|
+
The 402 body is in `self.body` and includes `accepts` (payment requirements),
|
|
26
|
+
`free_tier` (instructions to obtain a free key), and `x402` (protocol pointers).
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class QuotaExhaustedError(IBANforgeError):
|
|
31
|
+
"""The API key's monthly quota is exhausted.
|
|
32
|
+
|
|
33
|
+
Note: by default the IBANforge API will fall back to advertising x402 payment
|
|
34
|
+
requirements (HTTP 402) instead of returning 429 — agents that scale beyond
|
|
35
|
+
their quota can keep calling and pay per request. This exception is only
|
|
36
|
+
raised if the server explicitly returns 429.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class RateLimitError(IBANforgeError):
|
|
41
|
+
"""Global rate limit exceeded (per-IP)."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class InvalidInputError(IBANforgeError):
|
|
45
|
+
"""4xx returned for an obviously malformed request — bad IBAN length, bad BIC
|
|
46
|
+
format, missing query param, etc. Carries the server's `error_detail` if any.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class APIError(IBANforgeError):
|
|
51
|
+
"""5xx — server-side failure. Retry with backoff."""
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Typed dicts mirroring the IBANforge REST API response shapes.
|
|
2
|
+
|
|
3
|
+
These are duck-typed (TypedDict, not dataclass) so they accept the raw JSON
|
|
4
|
+
returned by the API without an extra parsing step. If you want strict
|
|
5
|
+
validation, wrap them in pydantic models on your end.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, List, Optional, TypedDict
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Country(TypedDict, total=False):
|
|
14
|
+
code: str
|
|
15
|
+
name: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BBAN(TypedDict, total=False):
|
|
19
|
+
bank_code: str
|
|
20
|
+
branch_code: str
|
|
21
|
+
account_number: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BIC(TypedDict, total=False):
|
|
25
|
+
bic: str
|
|
26
|
+
bankName: str
|
|
27
|
+
city: str
|
|
28
|
+
lei: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Issuer(TypedDict, total=False):
|
|
32
|
+
type: str # "bank" | "emi" | "viban" | "neobank" | "unknown"
|
|
33
|
+
name: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SEPA(TypedDict, total=False):
|
|
37
|
+
reachable: bool
|
|
38
|
+
instant: bool
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class VoP(TypedDict, total=False):
|
|
42
|
+
participant: bool
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class CHClearing(TypedDict, total=False):
|
|
46
|
+
bc_nummer: str
|
|
47
|
+
sic: bool
|
|
48
|
+
qr_iid: bool
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class IBANValidationResult(TypedDict, total=False):
|
|
52
|
+
iban: str
|
|
53
|
+
formatted: str
|
|
54
|
+
valid: bool
|
|
55
|
+
country: Country
|
|
56
|
+
check_digits: str
|
|
57
|
+
bban: BBAN
|
|
58
|
+
bic: BIC
|
|
59
|
+
issuer: Issuer
|
|
60
|
+
sepa: SEPA
|
|
61
|
+
vop: VoP
|
|
62
|
+
ch_clearing: CHClearing
|
|
63
|
+
risk_score: float
|
|
64
|
+
cost_usdc: float
|
|
65
|
+
processing_ms: int
|
|
66
|
+
error: str
|
|
67
|
+
error_detail: str
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class IBANFormatResult(TypedDict, total=False):
|
|
71
|
+
"""Free /v1/iban/format response — pure mod-97 + structure check, no DB lookups."""
|
|
72
|
+
|
|
73
|
+
iban: str
|
|
74
|
+
formatted: str
|
|
75
|
+
valid: bool
|
|
76
|
+
country: Country
|
|
77
|
+
check_digits: str
|
|
78
|
+
bban: BBAN
|
|
79
|
+
error: str
|
|
80
|
+
error_detail: str
|
|
81
|
+
upgrade_to_full_validation: str
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class IBANBatchResult(TypedDict, total=False):
|
|
85
|
+
results: List[IBANValidationResult]
|
|
86
|
+
summary: Any
|
|
87
|
+
cost_usdc: float
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class BICLookupResult(TypedDict, total=False):
|
|
91
|
+
bic: str
|
|
92
|
+
bic8: str
|
|
93
|
+
bic11: str
|
|
94
|
+
found: bool
|
|
95
|
+
valid_format: bool
|
|
96
|
+
institution: str
|
|
97
|
+
country: Country
|
|
98
|
+
city: str
|
|
99
|
+
lei: str
|
|
100
|
+
address: str
|
|
101
|
+
cost_usdc: float
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class CHClearingResult(TypedDict, total=False):
|
|
105
|
+
iid: str
|
|
106
|
+
found: bool
|
|
107
|
+
institution: Any
|
|
108
|
+
participation: Any
|
|
109
|
+
bic: str
|
|
110
|
+
cost_usdc: float
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class ComplianceResult(TypedDict, total=False):
|
|
114
|
+
iban: str
|
|
115
|
+
valid: bool
|
|
116
|
+
risk_score: float
|
|
117
|
+
recommended_action: str # "allow" | "review" | "block"
|
|
118
|
+
sanctions: Any
|
|
119
|
+
fatf: Any
|
|
120
|
+
sepa: SEPA
|
|
121
|
+
vop: VoP
|
|
122
|
+
flags: Any
|
|
123
|
+
cost_usdc: float
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class APIKey(TypedDict, total=False):
|
|
127
|
+
api_key: str
|
|
128
|
+
key_prefix: str
|
|
129
|
+
email: str
|
|
130
|
+
monthly_limit: int
|
|
131
|
+
message: str
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class APIKeyUsage(TypedDict, total=False):
|
|
135
|
+
key_prefix: str
|
|
136
|
+
email: str
|
|
137
|
+
monthly_limit: int
|
|
138
|
+
used_this_month: int
|
|
139
|
+
remaining: int
|
|
140
|
+
month: str
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class HealthInfo(TypedDict, total=False):
|
|
144
|
+
status: str
|
|
145
|
+
version: str
|
|
146
|
+
uptime_seconds: float
|
|
147
|
+
bic_database_entries: int
|
|
148
|
+
ch_clearing_entries: int
|
|
149
|
+
stats: Any
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ibanforge"
|
|
7
|
+
version = "1.1.0"
|
|
8
|
+
description = "Official Python SDK for the IBANforge API — IBAN validation, BIC/SWIFT lookup, Swiss BC-Nummer, sanctions/SEPA/VoP compliance triage."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "IBANforge", email = "support@ibanforge.com" }]
|
|
13
|
+
dependencies = ["httpx>=0.24.0"]
|
|
14
|
+
keywords = [
|
|
15
|
+
"iban",
|
|
16
|
+
"bic",
|
|
17
|
+
"swift",
|
|
18
|
+
"validation",
|
|
19
|
+
"banking",
|
|
20
|
+
"api",
|
|
21
|
+
"sepa",
|
|
22
|
+
"vop",
|
|
23
|
+
"fintech",
|
|
24
|
+
"ai-agent",
|
|
25
|
+
"x402",
|
|
26
|
+
"compliance",
|
|
27
|
+
"sanctions",
|
|
28
|
+
"swiss",
|
|
29
|
+
]
|
|
30
|
+
classifiers = [
|
|
31
|
+
"Development Status :: 5 - Production/Stable",
|
|
32
|
+
"Intended Audience :: Developers",
|
|
33
|
+
"Intended Audience :: Financial and Insurance Industry",
|
|
34
|
+
"License :: OSI Approved :: MIT License",
|
|
35
|
+
"Operating System :: OS Independent",
|
|
36
|
+
"Programming Language :: Python :: 3",
|
|
37
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
38
|
+
"Programming Language :: Python :: 3.9",
|
|
39
|
+
"Programming Language :: Python :: 3.10",
|
|
40
|
+
"Programming Language :: Python :: 3.11",
|
|
41
|
+
"Programming Language :: Python :: 3.12",
|
|
42
|
+
"Programming Language :: Python :: 3.13",
|
|
43
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
44
|
+
"Topic :: Office/Business :: Financial",
|
|
45
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
46
|
+
"Typing :: Typed",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
[project.optional-dependencies]
|
|
50
|
+
test = ["pytest>=7", "pytest-asyncio>=0.21", "respx>=0.20"]
|
|
51
|
+
|
|
52
|
+
[project.urls]
|
|
53
|
+
Homepage = "https://ibanforge.com"
|
|
54
|
+
Documentation = "https://ibanforge.com/docs"
|
|
55
|
+
"Agent Guide" = "https://ibanforge.com/agents"
|
|
56
|
+
"OpenAPI Reference" = "https://ibanforge.com/openapi"
|
|
57
|
+
Repository = "https://github.com/cammac-creator/ibanforge"
|
|
58
|
+
"Issue Tracker" = "https://github.com/cammac-creator/ibanforge/issues"
|
|
59
|
+
Changelog = "https://github.com/cammac-creator/ibanforge/blob/main/CHANGELOG.md"
|
|
60
|
+
|
|
61
|
+
[tool.hatch.build.targets.wheel]
|
|
62
|
+
packages = ["ibanforge"]
|
|
File without changes
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Unit tests for the sync IBANforge client.
|
|
2
|
+
|
|
3
|
+
Uses respx to mock httpx — no real network calls.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import pytest
|
|
10
|
+
import respx
|
|
11
|
+
|
|
12
|
+
from ibanforge import (
|
|
13
|
+
APIError,
|
|
14
|
+
AsyncIBANforge,
|
|
15
|
+
AuthError,
|
|
16
|
+
IBANforge,
|
|
17
|
+
InvalidInputError,
|
|
18
|
+
PaymentRequiredError,
|
|
19
|
+
QuotaExhaustedError,
|
|
20
|
+
RateLimitError,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
BASE = "https://api.ibanforge.com"
|
|
24
|
+
SAMPLE_IBAN = "CH9300762011623852957"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.fixture
|
|
28
|
+
def client() -> IBANforge:
|
|
29
|
+
return IBANforge(api_key="ifk_test_key")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@respx.mock
|
|
33
|
+
def test_format_iban_no_key_required():
|
|
34
|
+
cl = IBANforge() # no api_key
|
|
35
|
+
respx.get(f"{BASE}/v1/iban/format").mock(
|
|
36
|
+
return_value=httpx.Response(200, json={"iban": SAMPLE_IBAN, "valid": True})
|
|
37
|
+
)
|
|
38
|
+
out = cl.format_iban(SAMPLE_IBAN)
|
|
39
|
+
assert out["valid"] is True
|
|
40
|
+
cl.close()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@respx.mock
|
|
44
|
+
def test_validate_iban_authorization_header(client: IBANforge):
|
|
45
|
+
route = respx.post(f"{BASE}/v1/iban/validate").mock(
|
|
46
|
+
return_value=httpx.Response(200, json={"iban": SAMPLE_IBAN, "valid": True})
|
|
47
|
+
)
|
|
48
|
+
out = client.validate_iban(SAMPLE_IBAN)
|
|
49
|
+
assert out["valid"] is True
|
|
50
|
+
sent = route.calls.last.request
|
|
51
|
+
assert sent.headers.get("authorization") == "Bearer ifk_test_key"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@respx.mock
|
|
55
|
+
def test_validate_batch_too_many_raises_local(client: IBANforge):
|
|
56
|
+
too_many = [SAMPLE_IBAN] * 101
|
|
57
|
+
with pytest.raises(InvalidInputError):
|
|
58
|
+
client.validate_batch(too_many)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@respx.mock
|
|
62
|
+
def test_lookup_bic(client: IBANforge):
|
|
63
|
+
respx.get(f"{BASE}/v1/bic/UBSWCHZH80A").mock(
|
|
64
|
+
return_value=httpx.Response(200, json={"bic": "UBSWCHZH80A", "found": True})
|
|
65
|
+
)
|
|
66
|
+
out = client.lookup_bic("UBSWCHZH80A")
|
|
67
|
+
assert out["found"] is True
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@respx.mock
|
|
71
|
+
def test_lookup_ch_clearing(client: IBANforge):
|
|
72
|
+
respx.get(f"{BASE}/v1/ch/clearing/762").mock(
|
|
73
|
+
return_value=httpx.Response(200, json={"iid": "00762", "found": True})
|
|
74
|
+
)
|
|
75
|
+
out = client.lookup_ch_clearing(762)
|
|
76
|
+
assert out["found"] is True
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@respx.mock
|
|
80
|
+
def test_compliance(client: IBANforge):
|
|
81
|
+
respx.post(f"{BASE}/v1/iban/compliance").mock(
|
|
82
|
+
return_value=httpx.Response(
|
|
83
|
+
200, json={"iban": SAMPLE_IBAN, "valid": True, "risk_score": 5, "recommended_action": "allow"}
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
out = client.check_compliance(SAMPLE_IBAN)
|
|
87
|
+
assert out["recommended_action"] == "allow"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@respx.mock
|
|
91
|
+
def test_402_raises_payment_required(client: IBANforge):
|
|
92
|
+
respx.post(f"{BASE}/v1/iban/validate").mock(
|
|
93
|
+
return_value=httpx.Response(
|
|
94
|
+
402,
|
|
95
|
+
json={
|
|
96
|
+
"x402Version": 1,
|
|
97
|
+
"error": "payment_required",
|
|
98
|
+
"message": "Pay or use a key",
|
|
99
|
+
"accepts": [{"scheme": "exact", "network": "base"}],
|
|
100
|
+
},
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
with pytest.raises(PaymentRequiredError) as exc:
|
|
104
|
+
client.validate_iban(SAMPLE_IBAN)
|
|
105
|
+
assert exc.value.status == 402
|
|
106
|
+
assert exc.value.body["accepts"][0]["network"] == "base"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@respx.mock
|
|
110
|
+
def test_401_raises_auth_error(client: IBANforge):
|
|
111
|
+
respx.post(f"{BASE}/v1/iban/validate").mock(
|
|
112
|
+
return_value=httpx.Response(401, json={"error": "invalid_api_key"})
|
|
113
|
+
)
|
|
114
|
+
with pytest.raises(AuthError):
|
|
115
|
+
client.validate_iban(SAMPLE_IBAN)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@respx.mock
|
|
119
|
+
def test_429_quota_exceeded_distinct_from_rate_limit(client: IBANforge):
|
|
120
|
+
respx.post(f"{BASE}/v1/iban/validate").mock(
|
|
121
|
+
return_value=httpx.Response(429, json={"error": "quota_exceeded", "limit": 200, "used": 200})
|
|
122
|
+
)
|
|
123
|
+
with pytest.raises(QuotaExhaustedError):
|
|
124
|
+
client.validate_iban(SAMPLE_IBAN)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@respx.mock
|
|
128
|
+
def test_429_generic_rate_limit(client: IBANforge):
|
|
129
|
+
respx.post(f"{BASE}/v1/iban/validate").mock(
|
|
130
|
+
return_value=httpx.Response(429, json={"error": "rate_limited"})
|
|
131
|
+
)
|
|
132
|
+
with pytest.raises(RateLimitError):
|
|
133
|
+
client.validate_iban(SAMPLE_IBAN)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@respx.mock
|
|
137
|
+
def test_400_raises_invalid_input(client: IBANforge):
|
|
138
|
+
respx.post(f"{BASE}/v1/iban/validate").mock(
|
|
139
|
+
return_value=httpx.Response(400, json={"error": "invalid_iban", "error_detail": "too short"})
|
|
140
|
+
)
|
|
141
|
+
with pytest.raises(InvalidInputError) as exc:
|
|
142
|
+
client.validate_iban("CH")
|
|
143
|
+
assert "too short" in str(exc.value)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@respx.mock
|
|
147
|
+
def test_500_raises_api_error(client: IBANforge):
|
|
148
|
+
respx.post(f"{BASE}/v1/iban/validate").mock(return_value=httpx.Response(500, text="boom"))
|
|
149
|
+
with pytest.raises(APIError):
|
|
150
|
+
client.validate_iban(SAMPLE_IBAN)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@respx.mock
|
|
154
|
+
def test_generate_api_key_static_method():
|
|
155
|
+
respx.post(f"{BASE}/v1/keys/generate").mock(
|
|
156
|
+
return_value=httpx.Response(
|
|
157
|
+
200, json={"api_key": "ifk_xxx", "monthly_limit": 200, "email": "a@b.c"}
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
out = IBANforge.generate_api_key("a@b.c")
|
|
161
|
+
assert out["api_key"].startswith("ifk_")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_usage_without_key_raises():
|
|
165
|
+
cl = IBANforge() # no api_key
|
|
166
|
+
with pytest.raises(AuthError):
|
|
167
|
+
cl.usage()
|
|
168
|
+
cl.close()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@pytest.mark.asyncio
|
|
172
|
+
@respx.mock
|
|
173
|
+
async def test_async_validate_iban():
|
|
174
|
+
respx.post(f"{BASE}/v1/iban/validate").mock(
|
|
175
|
+
return_value=httpx.Response(200, json={"iban": SAMPLE_IBAN, "valid": True})
|
|
176
|
+
)
|
|
177
|
+
async with AsyncIBANforge(api_key="ifk_test_key") as cl:
|
|
178
|
+
out = await cl.validate_iban(SAMPLE_IBAN)
|
|
179
|
+
assert out["valid"] is True
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@pytest.mark.asyncio
|
|
183
|
+
@respx.mock
|
|
184
|
+
async def test_async_402():
|
|
185
|
+
respx.post(f"{BASE}/v1/iban/validate").mock(
|
|
186
|
+
return_value=httpx.Response(402, json={"error": "payment_required", "accepts": []})
|
|
187
|
+
)
|
|
188
|
+
async with AsyncIBANforge() as cl:
|
|
189
|
+
with pytest.raises(PaymentRequiredError):
|
|
190
|
+
await cl.validate_iban(SAMPLE_IBAN)
|