opaige 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.
- opaige-1.0.0/LICENSE +15 -0
- opaige-1.0.0/PKG-INFO +45 -0
- opaige-1.0.0/README.md +34 -0
- opaige-1.0.0/opaige/__init__.py +20 -0
- opaige-1.0.0/opaige/operations.py +171 -0
- opaige-1.0.0/opaige/request.py +71 -0
- opaige-1.0.0/opaige/webhooks.py +28 -0
- opaige-1.0.0/opaige.egg-info/PKG-INFO +45 -0
- opaige-1.0.0/opaige.egg-info/SOURCES.txt +11 -0
- opaige-1.0.0/opaige.egg-info/dependency_links.txt +1 -0
- opaige-1.0.0/opaige.egg-info/top_level.txt +1 -0
- opaige-1.0.0/pyproject.toml +19 -0
- opaige-1.0.0/setup.cfg +4 -0
opaige-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Opaige Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the
|
|
6
|
+
"Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to
|
|
8
|
+
the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
11
|
+
|
|
12
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
13
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
|
14
|
+
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
15
|
+
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
opaige-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: opaige
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: The Opaige API for Python: visa requirements, file review, forms, bookings, lodgements and webhooks.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://opaige.com/developers
|
|
7
|
+
Requires-Python: >=3.8
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Dynamic: license-file
|
|
11
|
+
|
|
12
|
+
# Opaige for Python
|
|
13
|
+
|
|
14
|
+
The Opaige API from Python 3.8 or later. Standard library only.
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
import os
|
|
18
|
+
from opaige import Opaige, OpaigeError, verify_webhook
|
|
19
|
+
|
|
20
|
+
opaige = Opaige(os.environ["OPAIGE_KEY"])
|
|
21
|
+
|
|
22
|
+
answer = opaige.check_requirements(passport="IN", destination="FR")
|
|
23
|
+
|
|
24
|
+
opaige.send_bookings(body={"bookings": [{
|
|
25
|
+
"ref": "QX7K2M", "flight": {"number": "OP412", "destination": "GB", "depart_date": "2026-12-18"},
|
|
26
|
+
"passengers": [{"ref": "QX7K2M-1", "passport": "IN", "residence": "AE"}],
|
|
27
|
+
}]})
|
|
28
|
+
|
|
29
|
+
review = opaige.review_file(file=pdf_bytes, fields={"destination": "FR", "purpose": "business"}, idempotency_key="file-8841")
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Every method is one API call, named as in the [API reference](https://opaige.com/developers), in snake_case. JSON goes in `body`;
|
|
33
|
+
a PDF comes back as `bytes`. A query value that is a Python word gets an underscore: `list_invoices(from_="2026-10-01")`.
|
|
34
|
+
|
|
35
|
+
**Errors** raise `OpaigeError` with `status`, `code`, `request_id` (quote it to support) and `details`.
|
|
36
|
+
**Retries:** reads, and writes sent with an `idempotency_key`, are tried again after 429 and 5xx answers (`max_retries`, default 2).
|
|
37
|
+
**Sandbox:** a sandbox key answers in the same shape and saves and bills nothing.
|
|
38
|
+
|
|
39
|
+
## Webhooks
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
event = verify_webhook(request.get_data(), request.headers.get("Opaige-Signature"), os.environ["OPAIGE_WEBHOOK_SECRET"])
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Pass the raw body: a parsed and re-serialised body no longer matches its signature.
|
opaige-1.0.0/README.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Opaige for Python
|
|
2
|
+
|
|
3
|
+
The Opaige API from Python 3.8 or later. Standard library only.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
import os
|
|
7
|
+
from opaige import Opaige, OpaigeError, verify_webhook
|
|
8
|
+
|
|
9
|
+
opaige = Opaige(os.environ["OPAIGE_KEY"])
|
|
10
|
+
|
|
11
|
+
answer = opaige.check_requirements(passport="IN", destination="FR")
|
|
12
|
+
|
|
13
|
+
opaige.send_bookings(body={"bookings": [{
|
|
14
|
+
"ref": "QX7K2M", "flight": {"number": "OP412", "destination": "GB", "depart_date": "2026-12-18"},
|
|
15
|
+
"passengers": [{"ref": "QX7K2M-1", "passport": "IN", "residence": "AE"}],
|
|
16
|
+
}]})
|
|
17
|
+
|
|
18
|
+
review = opaige.review_file(file=pdf_bytes, fields={"destination": "FR", "purpose": "business"}, idempotency_key="file-8841")
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Every method is one API call, named as in the [API reference](https://opaige.com/developers), in snake_case. JSON goes in `body`;
|
|
22
|
+
a PDF comes back as `bytes`. A query value that is a Python word gets an underscore: `list_invoices(from_="2026-10-01")`.
|
|
23
|
+
|
|
24
|
+
**Errors** raise `OpaigeError` with `status`, `code`, `request_id` (quote it to support) and `details`.
|
|
25
|
+
**Retries:** reads, and writes sent with an `idempotency_key`, are tried again after 429 and 5xx answers (`max_retries`, default 2).
|
|
26
|
+
**Sandbox:** a sandbox key answers in the same shape and saves and bills nothing.
|
|
27
|
+
|
|
28
|
+
## Webhooks
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
event = verify_webhook(request.get_data(), request.headers.get("Opaige-Signature"), os.environ["OPAIGE_WEBHOOK_SECRET"])
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Pass the raw body: a parsed and re-serialised body no longer matches its signature.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""The Opaige API for Python (2026-11-03). Every method is one API call, named as in the API reference (opaige.com/developers):
|
|
2
|
+
|
|
3
|
+
from opaige import Opaige
|
|
4
|
+
opaige = Opaige(os.environ["OPAIGE_KEY"])
|
|
5
|
+
answer = opaige.check_requirements(passport="IN", destination="FR")
|
|
6
|
+
opaige.send_bookings(body={"bookings": [...]})
|
|
7
|
+
|
|
8
|
+
A sandbox key answers in the same shape and saves and bills nothing. Standard library only.
|
|
9
|
+
"""
|
|
10
|
+
from .operations import Operations
|
|
11
|
+
from .request import VERSION, OpaigeError, Requester
|
|
12
|
+
from .webhooks import verify_webhook
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Opaige(Operations):
|
|
16
|
+
def __init__(self, key: str, base_url: str = "https://opaige.com/api/company/v1", timeout: float = 120.0, max_retries: int = 2) -> None:
|
|
17
|
+
super().__init__(Requester(key, base_url=base_url, timeout=timeout, max_retries=max_retries))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
__all__ = ["Opaige", "OpaigeError", "verify_webhook", "VERSION"]
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# Generated by scripts/sdk/generate.mjs from the OpenAPI description (2026-11-01). Do not edit by hand.
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Optional, Union
|
|
4
|
+
|
|
5
|
+
from .request import Requester
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Operations:
|
|
9
|
+
def __init__(self, http: Requester) -> None:
|
|
10
|
+
self._http = http
|
|
11
|
+
|
|
12
|
+
def list_appointments(self, *, date: Optional[Union[str, int]] = None) -> Any:
|
|
13
|
+
"""A day’s appointments with the file state and the counter stage. GET /appointments"""
|
|
14
|
+
return self._http.request("GET", f"/appointments", query={"date": date})
|
|
15
|
+
|
|
16
|
+
def send_appointments(self, *, body: Dict[str, Any]) -> Any:
|
|
17
|
+
"""Appointments, from a visa centre’s booking system. POST /appointments"""
|
|
18
|
+
return self._http.request("POST", f"/appointments", query=None, body=body)
|
|
19
|
+
|
|
20
|
+
def list_bookings(self, *, updated_since: Optional[Union[str, int]] = None) -> Any:
|
|
21
|
+
"""Your bookings with every nudge sent or due. GET /bookings"""
|
|
22
|
+
return self._http.request("GET", f"/bookings", query={"updated_since": updated_since})
|
|
23
|
+
|
|
24
|
+
def send_bookings(self, *, body: Dict[str, Any]) -> Any:
|
|
25
|
+
"""Bookings, from an airline’s or travel platform’s reservation system: who needs a visa or travel permission, and when each passenger is nudged. POST /bookings"""
|
|
26
|
+
return self._http.request("POST", f"/bookings", query=None, body=body)
|
|
27
|
+
|
|
28
|
+
def get_booking(self, *, ref: str) -> Any:
|
|
29
|
+
"""One booking by your reference. GET /bookings/{ref}"""
|
|
30
|
+
return self._http.request("GET", f"/bookings/{_q(ref)}", query=None)
|
|
31
|
+
|
|
32
|
+
def get_case_file(self, *, id: str) -> Any:
|
|
33
|
+
"""A case’s finished file, with fresh links. GET /cases/{id}/file"""
|
|
34
|
+
return self._http.request("GET", f"/cases/{_q(id)}/file", query=None)
|
|
35
|
+
|
|
36
|
+
def resend_case_file(self, *, id: str) -> Any:
|
|
37
|
+
"""Send file.ready again. POST /cases/{id}/file"""
|
|
38
|
+
return self._http.request("POST", f"/cases/{_q(id)}/file", query=None)
|
|
39
|
+
|
|
40
|
+
def make_document(self, *, body: Dict[str, Any], format: Optional[Union[str, int]] = None) -> Any:
|
|
41
|
+
"""An itinerary, cover letter, submission brief or third-party letter. POST /documents"""
|
|
42
|
+
return self._http.request("POST", f"/documents", query={"format": format}, body=body)
|
|
43
|
+
|
|
44
|
+
def review_file(self, *, body: Optional[Dict[str, Any]] = None, file: Optional[bytes] = None, filename: str = "file.pdf", fields: Optional[Dict[str, str]] = None, idempotency_key: Optional[str] = None) -> Any:
|
|
45
|
+
"""Every inconsistency in a whole visa file. POST /file-review"""
|
|
46
|
+
return self._http.request("POST", f"/file-review", query=None, body=body, file=file, filename=filename, fields=fields, idempotency_key=idempotency_key)
|
|
47
|
+
|
|
48
|
+
def get_file_review(self, *, id: str) -> Any:
|
|
49
|
+
"""A review made earlier. GET /file-review/{id}"""
|
|
50
|
+
return self._http.request("GET", f"/file-review/{_q(id)}", query=None)
|
|
51
|
+
|
|
52
|
+
def create_upload(self) -> Any:
|
|
53
|
+
"""A place to PUT a large PDF before reviewing it. POST /file-review/uploads"""
|
|
54
|
+
return self._http.request("POST", f"/file-review/uploads", query=None)
|
|
55
|
+
|
|
56
|
+
def list_ready_files(self, *, since: Optional[Union[str, int]] = None) -> Any:
|
|
57
|
+
"""Every file that became ready since a moment: the daily batch. GET /files"""
|
|
58
|
+
return self._http.request("GET", f"/files", query={"since": since})
|
|
59
|
+
|
|
60
|
+
def fill_form(self, *, body: Dict[str, Any], format: Optional[Union[str, int]] = None) -> Any:
|
|
61
|
+
"""The official visa form, filled from answers. POST /forms"""
|
|
62
|
+
return self._http.request("POST", f"/forms", query={"format": format}, body=body)
|
|
63
|
+
|
|
64
|
+
def list_invoices(self, *, status: Optional[Union[str, int]] = None, from_: Optional[Union[str, int]] = None, to: Optional[Union[str, int]] = None) -> Any:
|
|
65
|
+
"""Every case billed, due or paid. GET /invoices"""
|
|
66
|
+
return self._http.request("GET", f"/invoices", query={"status": status, "from": from_, "to": to})
|
|
67
|
+
|
|
68
|
+
def list_lodgements(self) -> Any:
|
|
69
|
+
"""Your lodgements, newest first. GET /lodgements"""
|
|
70
|
+
return self._http.request("GET", f"/lodgements", query=None)
|
|
71
|
+
|
|
72
|
+
def create_lodgement(self, *, body: Dict[str, Any], idempotency_key: Optional[str] = None) -> Any:
|
|
73
|
+
"""Fill a visa application on the government portal, parked before the declarations (au-600) or the photo and signature (us-ds160). POST /lodgements"""
|
|
74
|
+
return self._http.request("POST", f"/lodgements", query=None, body=body, idempotency_key=idempotency_key)
|
|
75
|
+
|
|
76
|
+
def get_lodgement(self, *, id: str) -> Any:
|
|
77
|
+
"""Where a lodgement stands, with the government reference once there is one. GET /lodgements/{id}"""
|
|
78
|
+
return self._http.request("GET", f"/lodgements/{_q(id)}", query=None)
|
|
79
|
+
|
|
80
|
+
def attach_lodgement_documents(self, *, id: str, body: Dict[str, Any]) -> Any:
|
|
81
|
+
"""Attach supporting documents. POST /lodgements/{id}/documents"""
|
|
82
|
+
return self._http.request("POST", f"/lodgements/{_q(id)}/documents", query=None, body=body)
|
|
83
|
+
|
|
84
|
+
def submit_lodgement(self, *, id: str, body: Dict[str, Any], idempotency_key: Optional[str] = None) -> Any:
|
|
85
|
+
"""Lodge and pay the government fee (requires authorize: true). POST /lodgements/{id}/submit"""
|
|
86
|
+
return self._http.request("POST", f"/lodgements/{_q(id)}/submit", query=None, body=body, idempotency_key=idempotency_key)
|
|
87
|
+
|
|
88
|
+
def lodgement_questions(self, *, visa: Optional[Union[str, int]] = None, purpose: Optional[Union[str, int]] = None) -> Any:
|
|
89
|
+
"""The questions a DS-160 lodgement answers, as a case asks them. GET /lodgements/questions"""
|
|
90
|
+
return self._http.request("GET", f"/lodgements/questions", query={"visa": visa, "purpose": purpose})
|
|
91
|
+
|
|
92
|
+
def check_passengers(self, *, body: Dict[str, Any]) -> Any:
|
|
93
|
+
"""Who on a flight needs a visa, and by when to file. POST /passengers/check"""
|
|
94
|
+
return self._http.request("POST", f"/passengers/check", query=None, body=body)
|
|
95
|
+
|
|
96
|
+
def give_passenger_file(self, *, body: Dict[str, Any], idempotency_key: Optional[str] = None) -> Any:
|
|
97
|
+
"""Give a passenger their visa file (billed per file). POST /passengers/file"""
|
|
98
|
+
return self._http.request("POST", f"/passengers/file", query=None, body=body, idempotency_key=idempotency_key)
|
|
99
|
+
|
|
100
|
+
def get_passenger_settings(self) -> Any:
|
|
101
|
+
"""Your page for buying the file, and where passengers get help. GET /passengers/settings"""
|
|
102
|
+
return self._http.request("GET", f"/passengers/settings", query=None)
|
|
103
|
+
|
|
104
|
+
def update_passenger_settings(self, *, body: Dict[str, Any]) -> Any:
|
|
105
|
+
"""Change them (null clears). PATCH /passengers/settings"""
|
|
106
|
+
return self._http.request("PATCH", f"/passengers/settings", query=None, body=body)
|
|
107
|
+
|
|
108
|
+
def passenger_status(self, *, ref: Optional[Union[str, int]] = None) -> Any:
|
|
109
|
+
"""Stages of passengers who chose to share. GET /passengers/status"""
|
|
110
|
+
return self._http.request("GET", f"/passengers/status", query={"ref": ref})
|
|
111
|
+
|
|
112
|
+
def check_requirements(self, *, passport: Optional[Union[str, int]] = None, destination: Optional[Union[str, int]] = None, residence: Optional[Union[str, int]] = None, stay_days: Optional[Union[str, int]] = None) -> Any:
|
|
113
|
+
"""What a passport needs for a destination. GET /requirements"""
|
|
114
|
+
return self._http.request("GET", f"/requirements", query={"passport": passport, "destination": destination, "residence": residence, "stay_days": stay_days})
|
|
115
|
+
|
|
116
|
+
def where_to_apply(self, *, residence: Optional[Union[str, int]] = None, destination: Optional[Union[str, int]] = None) -> Any:
|
|
117
|
+
"""Who takes the application, where, and who decides it. GET /routes"""
|
|
118
|
+
return self._http.request("GET", f"/routes", query={"residence": residence, "destination": destination})
|
|
119
|
+
|
|
120
|
+
def list_rules(self) -> Any:
|
|
121
|
+
"""Every destination the rules cover, with versions. GET /rules"""
|
|
122
|
+
return self._http.request("GET", f"/rules", query=None)
|
|
123
|
+
|
|
124
|
+
def get_rules(self, *, destination: str) -> Any:
|
|
125
|
+
"""One destination: every passport grouped by what it needs, with sources. GET /rules/{destination}"""
|
|
126
|
+
return self._http.request("GET", f"/rules/{_q(destination)}", query=None)
|
|
127
|
+
|
|
128
|
+
def list_rule_notices(self, *, since: Optional[Union[str, int]] = None) -> Any:
|
|
129
|
+
"""Rule changes announced, in force or coming up. GET /rules/notices"""
|
|
130
|
+
return self._http.request("GET", f"/rules/notices", query={"since": since})
|
|
131
|
+
|
|
132
|
+
def list_students(self) -> Any:
|
|
133
|
+
"""Admitted students, with the visa stage where each agreed to share it. GET /students"""
|
|
134
|
+
return self._http.request("GET", f"/students", query=None)
|
|
135
|
+
|
|
136
|
+
def send_students(self, *, body: Dict[str, Any]) -> Any:
|
|
137
|
+
"""Admitted students, from a school’s admissions system; each gets a visa link. POST /students"""
|
|
138
|
+
return self._http.request("POST", f"/students", query=None, body=body)
|
|
139
|
+
|
|
140
|
+
def sponsor_compliance(self, *, as_of: Optional[Union[str, int]] = None) -> Any:
|
|
141
|
+
"""A UK sponsor’s refusal, enrolment and completion rates as the Basic Compliance Assessment works them. GET /students/compliance"""
|
|
142
|
+
return self._http.request("GET", f"/students/compliance", query={"as_of": as_of})
|
|
143
|
+
|
|
144
|
+
def student_funds_check(self, *, body: Dict[str, Any]) -> Any:
|
|
145
|
+
"""Pre-CAS: a UK Student’s money against the Immigration Rules, each finding with its paragraph. POST /students/funds-check"""
|
|
146
|
+
return self._http.request("POST", f"/students/funds-check", query=None, body=body)
|
|
147
|
+
|
|
148
|
+
def list_trips(self, *, status: Optional[Union[str, int]] = None, updated_since: Optional[Union[str, int]] = None) -> Any:
|
|
149
|
+
"""Trips and where each case stands. GET /trips"""
|
|
150
|
+
return self._http.request("GET", f"/trips", query={"status": status, "updated_since": updated_since})
|
|
151
|
+
|
|
152
|
+
def create_trip(self, *, body: Dict[str, Any]) -> Any:
|
|
153
|
+
"""Plan a trip; it follows your approval rules. POST /trips"""
|
|
154
|
+
return self._http.request("POST", f"/trips", query=None, body=body)
|
|
155
|
+
|
|
156
|
+
def get_trip(self, *, id: str) -> Any:
|
|
157
|
+
"""One trip, with its case. GET /trips/{id}"""
|
|
158
|
+
return self._http.request("GET", f"/trips/{_q(id)}", query=None)
|
|
159
|
+
|
|
160
|
+
def get_uptime(self, *, month: Optional[Union[str, int]] = None) -> Any:
|
|
161
|
+
"""A month’s measured uptime against the 99.9% commitment. GET /uptime"""
|
|
162
|
+
return self._http.request("GET", f"/uptime", query={"month": month})
|
|
163
|
+
|
|
164
|
+
def get_usage(self, *, month: Optional[Union[str, int]] = None) -> Any:
|
|
165
|
+
"""This month’s paid API use and what it comes to. GET /usage"""
|
|
166
|
+
return self._http.request("GET", f"/usage", query={"month": month})
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _q(v: str) -> str:
|
|
170
|
+
from urllib.parse import quote
|
|
171
|
+
return quote(str(v), safe="")
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""How every call is made: the key, the JSON or the file, retries and errors. Written by hand; the API methods beside it
|
|
2
|
+
(operations.py) are generated from the API description. Standard library only."""
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
import uuid
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.parse
|
|
8
|
+
import urllib.request
|
|
9
|
+
from typing import Any, Dict, Optional
|
|
10
|
+
|
|
11
|
+
VERSION = "1.0.0"
|
|
12
|
+
_RETRY = {429, 500, 502, 503, 504}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class OpaigeError(Exception):
|
|
16
|
+
def __init__(self, message: str, status: int, code: Optional[str] = None, request_id: Optional[str] = None, details: Any = None):
|
|
17
|
+
super().__init__(message)
|
|
18
|
+
self.status, self.code, self.request_id, self.details = status, code, request_id, details
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Requester:
|
|
22
|
+
def __init__(self, key: str, base_url: str = "https://opaige.com/api/company/v1", timeout: float = 120.0, max_retries: int = 2):
|
|
23
|
+
if not key:
|
|
24
|
+
raise ValueError("An Opaige API key is required: create one in the portal under Developers.")
|
|
25
|
+
self._key, self._base, self._timeout, self._retries = key, base_url.rstrip("/"), timeout, max_retries
|
|
26
|
+
|
|
27
|
+
def request(self, method: str, path: str, query: Optional[Dict[str, Any]] = None, body: Any = None,
|
|
28
|
+
file: Optional[bytes] = None, filename: str = "file.pdf", fields: Optional[Dict[str, str]] = None,
|
|
29
|
+
idempotency_key: Optional[str] = None) -> Any:
|
|
30
|
+
q = {k: v for k, v in (query or {}).items() if v is not None and v != ""}
|
|
31
|
+
url = self._base + path + ("?" + urllib.parse.urlencode(q) if q else "")
|
|
32
|
+
headers = {"Authorization": f"Bearer {self._key}", "User-Agent": f"opaige-python/{VERSION}", "Accept": "application/json"}
|
|
33
|
+
if idempotency_key:
|
|
34
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
35
|
+
data = None
|
|
36
|
+
if file is not None:
|
|
37
|
+
boundary = "opaige" + uuid.uuid4().hex
|
|
38
|
+
parts = [f'--{boundary}\r\nContent-Disposition: form-data; name="{k}"\r\n\r\n{v}\r\n'.encode() for k, v in (fields or {}).items()]
|
|
39
|
+
parts.append(f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="{filename}"\r\nContent-Type: application/pdf\r\n\r\n'.encode() + file + b"\r\n")
|
|
40
|
+
data = b"".join(parts) + f"--{boundary}--\r\n".encode()
|
|
41
|
+
headers["Content-Type"] = f"multipart/form-data; boundary={boundary}"
|
|
42
|
+
elif body is not None:
|
|
43
|
+
data = json.dumps(body).encode()
|
|
44
|
+
headers["Content-Type"] = "application/json"
|
|
45
|
+
retryable = method == "GET" or bool(idempotency_key)
|
|
46
|
+
attempt = 0
|
|
47
|
+
while True:
|
|
48
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
49
|
+
try:
|
|
50
|
+
with urllib.request.urlopen(req, timeout=self._timeout) as res:
|
|
51
|
+
raw = res.read()
|
|
52
|
+
if "application/pdf" in (res.headers.get("Content-Type") or ""):
|
|
53
|
+
return raw
|
|
54
|
+
return json.loads(raw or b"{}")
|
|
55
|
+
except urllib.error.HTTPError as e:
|
|
56
|
+
if e.code in _RETRY and retryable and attempt < self._retries:
|
|
57
|
+
wait = e.headers.get("Retry-After")
|
|
58
|
+
time.sleep(min(float(wait), 30.0) if wait and wait.replace(".", "", 1).isdigit() else 0.5 * 2 ** attempt)
|
|
59
|
+
attempt += 1
|
|
60
|
+
continue
|
|
61
|
+
try:
|
|
62
|
+
out = json.loads(e.read() or b"{}")
|
|
63
|
+
except ValueError:
|
|
64
|
+
out = {}
|
|
65
|
+
raise OpaigeError(out.get("error") or f"Opaige answered {e.code}", e.code, out.get("code"), e.headers.get("Opaige-Request-Id"), out.get("details")) from None
|
|
66
|
+
except urllib.error.URLError as e:
|
|
67
|
+
if retryable and attempt < self._retries:
|
|
68
|
+
time.sleep(0.5 * 2 ** attempt)
|
|
69
|
+
attempt += 1
|
|
70
|
+
continue
|
|
71
|
+
raise OpaigeError(f"Could not reach Opaige: {e.reason}", 0, "network_error") from None
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Checks that a webhook came from Opaige and was not replayed.
|
|
2
|
+
|
|
3
|
+
Pass the raw request body exactly as received, the Opaige-Signature header and your endpoint's signing secret:
|
|
4
|
+
Opaige-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">
|
|
5
|
+
"""
|
|
6
|
+
import hashlib
|
|
7
|
+
import hmac
|
|
8
|
+
import json
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any, Dict, Optional, Union
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def verify_webhook(raw_body: Union[str, bytes], signature: Optional[str], secret: str, tolerance_seconds: int = 300, now: Optional[float] = None) -> Dict[str, Any]:
|
|
14
|
+
body = raw_body.decode() if isinstance(raw_body, bytes) else raw_body
|
|
15
|
+
parts = dict(p.strip().split("=", 1) for p in (signature or "").split(",") if "=" in p)
|
|
16
|
+
try:
|
|
17
|
+
t = int(parts.get("t", ""))
|
|
18
|
+
except ValueError:
|
|
19
|
+
raise ValueError("Opaige-Signature is missing or malformed.") from None
|
|
20
|
+
v1 = parts.get("v1")
|
|
21
|
+
if not v1:
|
|
22
|
+
raise ValueError("Opaige-Signature is missing or malformed.")
|
|
23
|
+
if abs((now if now is not None else time.time()) - t) > tolerance_seconds:
|
|
24
|
+
raise ValueError("Opaige-Signature is too old: a replay, or a clock far off.")
|
|
25
|
+
want = hmac.new(secret.encode(), f"{t}.{body}".encode(), hashlib.sha256).hexdigest()
|
|
26
|
+
if not hmac.compare_digest(want, v1):
|
|
27
|
+
raise ValueError("Opaige-Signature does not match this body and secret.")
|
|
28
|
+
return json.loads(body)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: opaige
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: The Opaige API for Python: visa requirements, file review, forms, bookings, lodgements and webhooks.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://opaige.com/developers
|
|
7
|
+
Requires-Python: >=3.8
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Dynamic: license-file
|
|
11
|
+
|
|
12
|
+
# Opaige for Python
|
|
13
|
+
|
|
14
|
+
The Opaige API from Python 3.8 or later. Standard library only.
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
import os
|
|
18
|
+
from opaige import Opaige, OpaigeError, verify_webhook
|
|
19
|
+
|
|
20
|
+
opaige = Opaige(os.environ["OPAIGE_KEY"])
|
|
21
|
+
|
|
22
|
+
answer = opaige.check_requirements(passport="IN", destination="FR")
|
|
23
|
+
|
|
24
|
+
opaige.send_bookings(body={"bookings": [{
|
|
25
|
+
"ref": "QX7K2M", "flight": {"number": "OP412", "destination": "GB", "depart_date": "2026-12-18"},
|
|
26
|
+
"passengers": [{"ref": "QX7K2M-1", "passport": "IN", "residence": "AE"}],
|
|
27
|
+
}]})
|
|
28
|
+
|
|
29
|
+
review = opaige.review_file(file=pdf_bytes, fields={"destination": "FR", "purpose": "business"}, idempotency_key="file-8841")
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Every method is one API call, named as in the [API reference](https://opaige.com/developers), in snake_case. JSON goes in `body`;
|
|
33
|
+
a PDF comes back as `bytes`. A query value that is a Python word gets an underscore: `list_invoices(from_="2026-10-01")`.
|
|
34
|
+
|
|
35
|
+
**Errors** raise `OpaigeError` with `status`, `code`, `request_id` (quote it to support) and `details`.
|
|
36
|
+
**Retries:** reads, and writes sent with an `idempotency_key`, are tried again after 429 and 5xx answers (`max_retries`, default 2).
|
|
37
|
+
**Sandbox:** a sandbox key answers in the same shape and saves and bills nothing.
|
|
38
|
+
|
|
39
|
+
## Webhooks
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
event = verify_webhook(request.get_data(), request.headers.get("Opaige-Signature"), os.environ["OPAIGE_WEBHOOK_SECRET"])
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Pass the raw body: a parsed and re-serialised body no longer matches its signature.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
opaige
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "opaige"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "The Opaige API for Python: visa requirements, file review, forms, bookings, lodgements and webhooks."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.8"
|
|
9
|
+
dependencies = []
|
|
10
|
+
|
|
11
|
+
[project.urls]
|
|
12
|
+
Homepage = "https://opaige.com/developers"
|
|
13
|
+
|
|
14
|
+
[build-system]
|
|
15
|
+
requires = ["setuptools>=77"]
|
|
16
|
+
build-backend = "setuptools.build_meta"
|
|
17
|
+
|
|
18
|
+
[tool.setuptools]
|
|
19
|
+
packages = ["opaige"]
|
opaige-1.0.0/setup.cfg
ADDED