railhook 2.12.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.
- railhook-2.12.0/PKG-INFO +536 -0
- railhook-2.12.0/README.md +507 -0
- railhook-2.12.0/pyproject.toml +50 -0
- railhook-2.12.0/railhook/__init__.py +87 -0
- railhook-2.12.0/railhook/client.py +548 -0
- railhook-2.12.0/railhook/errors.py +63 -0
- railhook-2.12.0/railhook/types.py +679 -0
- railhook-2.12.0/railhook/webhooks.py +273 -0
- railhook-2.12.0/railhook.egg-info/PKG-INFO +536 -0
- railhook-2.12.0/railhook.egg-info/SOURCES.txt +23 -0
- railhook-2.12.0/railhook.egg-info/dependency_links.txt +1 -0
- railhook-2.12.0/railhook.egg-info/requires.txt +7 -0
- railhook-2.12.0/railhook.egg-info/top_level.txt +4 -0
- railhook-2.12.0/scripts/live_api_smoke.py +493 -0
- railhook-2.12.0/setup.cfg +4 -0
- railhook-2.12.0/tests/__init__.py +0 -0
- railhook-2.12.0/tests/contract/__init__.py +0 -0
- railhook-2.12.0/tests/contract/conftest.py +18 -0
- railhook-2.12.0/tests/contract/support.py +84 -0
- railhook-2.12.0/tests/contract/test_client_contract.py +99 -0
- railhook-2.12.0/tests/test_client.py +332 -0
- railhook-2.12.0/tests/test_incoming.py +347 -0
- railhook-2.12.0/tests/test_package_identity.py +30 -0
- railhook-2.12.0/tests/test_standard_webhooks.py +91 -0
- railhook-2.12.0/tests/test_webhooks.py +307 -0
railhook-2.12.0/PKG-INFO
ADDED
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: railhook
|
|
3
|
+
Version: 2.12.0
|
|
4
|
+
Summary: Official Python SDK for Railhook — reliable webhook infrastructure
|
|
5
|
+
Author-email: Vadym Kykalo <vadymkykalo@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/vadymkykalo/railhook
|
|
8
|
+
Project-URL: Repository, https://github.com/vadymkykalo/railhook
|
|
9
|
+
Project-URL: Documentation, https://github.com/vadymkykalo/railhook#readme
|
|
10
|
+
Keywords: webhook,webhooks,api,events,delivery
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.8
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Requires-Dist: requests>=2.28.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
|
|
27
|
+
Requires-Dist: mypy>=1.0.0; extra == "dev"
|
|
28
|
+
Requires-Dist: types-requests>=2.28.0; extra == "dev"
|
|
29
|
+
|
|
30
|
+
# railhook
|
|
31
|
+
|
|
32
|
+
Official Python SDK for [Railhook](https://github.com/vadymkykalo/railhook).
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install railhook
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
> Published as `webhook-platform` before 2.12.0, importable as `hookflow`. That
|
|
39
|
+
> package is not updated any further; install `railhook` and change the import.
|
|
40
|
+
|
|
41
|
+
**Scope.** This SDK covers Events, Endpoints, Subscriptions, Deliveries,
|
|
42
|
+
Incoming Sources, Incoming Events, and webhook signature verification —
|
|
43
|
+
7 of the platform's 35 API controllers. It does not wrap
|
|
44
|
+
Transformations, Rules, Workflows, Schemas, DLQ, Analytics, Usage, Alerts,
|
|
45
|
+
Incidents, PII rules, Audit Log, Tunnels, API keys, Members, or Projects —
|
|
46
|
+
use the [Generic Requests](#generic-requests) helpers for those until the
|
|
47
|
+
SDK grows to cover them.
|
|
48
|
+
|
|
49
|
+
## Installation
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install railhook
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Quick Start
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
import os
|
|
59
|
+
|
|
60
|
+
from railhook import Railhook, Event
|
|
61
|
+
|
|
62
|
+
client = Railhook(
|
|
63
|
+
api_key=os.environ["RAILHOOK_API_KEY"], # e.g. "Kz1uAIM8VeJUQN7yGSYCst64WxNLabBHfOYbrPlJ1yk"
|
|
64
|
+
base_url="http://localhost:8080", # optional
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# Send an event
|
|
68
|
+
event = client.events.send(
|
|
69
|
+
Event(
|
|
70
|
+
type="order.completed",
|
|
71
|
+
data={
|
|
72
|
+
"order_id": "ord_12345",
|
|
73
|
+
"amount": 99.99,
|
|
74
|
+
"currency": "USD",
|
|
75
|
+
},
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
print(f"Event created: {event.event_id}")
|
|
80
|
+
print(f"Deliveries created: {event.deliveries_created}")
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## API Reference
|
|
84
|
+
|
|
85
|
+
### Events
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from railhook import Event
|
|
89
|
+
|
|
90
|
+
# Send event with idempotency key
|
|
91
|
+
event = client.events.send(
|
|
92
|
+
Event(type="order.completed", data={"order_id": "123"}),
|
|
93
|
+
idempotency_key="unique-key",
|
|
94
|
+
)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Endpoints
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from railhook import EndpointCreateParams, EndpointUpdateParams
|
|
101
|
+
|
|
102
|
+
# Create endpoint
|
|
103
|
+
endpoint = client.endpoints.create(
|
|
104
|
+
project_id,
|
|
105
|
+
EndpointCreateParams(
|
|
106
|
+
url="https://api.example.com/webhooks",
|
|
107
|
+
description="Production webhooks",
|
|
108
|
+
enabled=True,
|
|
109
|
+
),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# List endpoints — the API paginates this one, so the endpoints are in .content
|
|
113
|
+
# (iterating the page yields them directly)
|
|
114
|
+
page = client.endpoints.list(project_id, page=0, size=20)
|
|
115
|
+
for endpoint in page:
|
|
116
|
+
print(endpoint.url)
|
|
117
|
+
|
|
118
|
+
# Update endpoint
|
|
119
|
+
client.endpoints.update(
|
|
120
|
+
project_id,
|
|
121
|
+
endpoint_id,
|
|
122
|
+
EndpointUpdateParams(enabled=False),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# Delete endpoint
|
|
126
|
+
client.endpoints.delete(project_id, endpoint_id)
|
|
127
|
+
|
|
128
|
+
# Rotate secret
|
|
129
|
+
updated = client.endpoints.rotate_secret(project_id, endpoint_id)
|
|
130
|
+
print(f"New secret: {updated.secret}")
|
|
131
|
+
|
|
132
|
+
# Test endpoint connectivity
|
|
133
|
+
result = client.endpoints.test(project_id, endpoint_id)
|
|
134
|
+
print(f"Test {'passed' if result.success else 'failed'}: {result.latency_ms}ms")
|
|
135
|
+
print(f"{result.http_status_code} — {result.message}")
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Subscriptions
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
from railhook import SubscriptionCreateParams
|
|
142
|
+
|
|
143
|
+
# Subscribe endpoint to an event type
|
|
144
|
+
subscription = client.subscriptions.create(
|
|
145
|
+
project_id,
|
|
146
|
+
SubscriptionCreateParams(
|
|
147
|
+
endpoint_id=endpoint.id,
|
|
148
|
+
event_type="order.completed",
|
|
149
|
+
enabled=True,
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# List subscriptions — a bare list; unlike endpoints, this one is not paginated
|
|
154
|
+
subscriptions = client.subscriptions.list(project_id)
|
|
155
|
+
|
|
156
|
+
# Update subscription
|
|
157
|
+
client.subscriptions.update(
|
|
158
|
+
project_id,
|
|
159
|
+
subscription_id,
|
|
160
|
+
event_type="order.shipped",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# Delete subscription
|
|
164
|
+
client.subscriptions.delete(project_id, subscription_id)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### Deliveries
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
from railhook import DeliveryListParams, DeliveryStatus
|
|
171
|
+
|
|
172
|
+
# List deliveries with filters
|
|
173
|
+
deliveries = client.deliveries.list(
|
|
174
|
+
project_id,
|
|
175
|
+
DeliveryListParams(status=DeliveryStatus.FAILED, page=0, size=20),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
print(f"Total failed: {deliveries.total_elements}")
|
|
179
|
+
|
|
180
|
+
# Get delivery attempts
|
|
181
|
+
attempts = client.deliveries.get_attempts(delivery_id)
|
|
182
|
+
for attempt in attempts:
|
|
183
|
+
print(f"Attempt {attempt.attempt_number}: {attempt.http_status_code} ({attempt.duration_ms}ms)")
|
|
184
|
+
|
|
185
|
+
# Replay failed delivery
|
|
186
|
+
client.deliveries.replay(delivery_id)
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Incoming Webhooks
|
|
190
|
+
|
|
191
|
+
Receive, validate, and forward webhooks from third-party providers (Stripe, GitHub, Twilio, etc.).
|
|
192
|
+
|
|
193
|
+
### Incoming Sources
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
from railhook import IncomingSourceCreateParams, IncomingSourceUpdateParams
|
|
197
|
+
|
|
198
|
+
# Create an incoming source with HMAC verification
|
|
199
|
+
source = client.incoming_sources.create(
|
|
200
|
+
project_id,
|
|
201
|
+
IncomingSourceCreateParams(
|
|
202
|
+
name="Stripe Webhooks",
|
|
203
|
+
slug="stripe",
|
|
204
|
+
provider_type="STRIPE",
|
|
205
|
+
verification_mode="HMAC_GENERIC",
|
|
206
|
+
hmac_secret="whsec_...",
|
|
207
|
+
hmac_header_name="Stripe-Signature",
|
|
208
|
+
),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
print(f"Ingress URL: {source.ingress_url}")
|
|
212
|
+
|
|
213
|
+
# List sources
|
|
214
|
+
sources = client.incoming_sources.list(project_id)
|
|
215
|
+
|
|
216
|
+
# Update source
|
|
217
|
+
client.incoming_sources.update(
|
|
218
|
+
project_id,
|
|
219
|
+
source_id,
|
|
220
|
+
IncomingSourceUpdateParams(name="Stripe Production", rate_limit_per_second=100),
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# Delete source
|
|
224
|
+
client.incoming_sources.delete(project_id, source_id)
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
### Incoming Destinations
|
|
228
|
+
|
|
229
|
+
```python
|
|
230
|
+
from railhook import IncomingDestinationCreateParams
|
|
231
|
+
|
|
232
|
+
# Add a forwarding destination
|
|
233
|
+
dest = client.incoming_sources.create_destination(
|
|
234
|
+
project_id,
|
|
235
|
+
source_id,
|
|
236
|
+
IncomingDestinationCreateParams(
|
|
237
|
+
url="https://your-api.com/webhooks/stripe",
|
|
238
|
+
enabled=True,
|
|
239
|
+
max_attempts=5,
|
|
240
|
+
timeout_seconds=30,
|
|
241
|
+
),
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
# List destinations
|
|
245
|
+
dests = client.incoming_sources.list_destinations(project_id, source_id)
|
|
246
|
+
|
|
247
|
+
# Delete destination
|
|
248
|
+
client.incoming_sources.delete_destination(project_id, source_id, dest_id)
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
### Incoming Events
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
from railhook import IncomingEventListParams
|
|
255
|
+
|
|
256
|
+
# List incoming events (with optional source filter)
|
|
257
|
+
events = client.incoming_events.list(
|
|
258
|
+
project_id,
|
|
259
|
+
IncomingEventListParams(source_id=source.id, page=0, size=20),
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
# Get event details
|
|
263
|
+
event = client.incoming_events.get(project_id, event_id)
|
|
264
|
+
|
|
265
|
+
# Get forward attempts
|
|
266
|
+
attempts = client.incoming_events.get_attempts(project_id, event_id)
|
|
267
|
+
|
|
268
|
+
# Replay event to all destinations
|
|
269
|
+
result = client.incoming_events.replay(project_id, event_id)
|
|
270
|
+
print(f"Replayed to {result.destinations_count} destinations")
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
## Webhook Signature Verification
|
|
274
|
+
|
|
275
|
+
Verify incoming webhooks in your endpoint:
|
|
276
|
+
|
|
277
|
+
```python
|
|
278
|
+
from railhook import verify_signature, construct_event, RailhookError
|
|
279
|
+
|
|
280
|
+
# Flask example
|
|
281
|
+
from flask import Flask, request
|
|
282
|
+
|
|
283
|
+
app = Flask(__name__)
|
|
284
|
+
|
|
285
|
+
@app.route("/webhooks", methods=["POST"])
|
|
286
|
+
def handle_webhook():
|
|
287
|
+
payload = request.get_data(as_text=True)
|
|
288
|
+
headers = dict(request.headers)
|
|
289
|
+
secret = os.environ["WEBHOOK_SECRET"]
|
|
290
|
+
|
|
291
|
+
try:
|
|
292
|
+
# Option 1: Just verify
|
|
293
|
+
verify_signature(payload, headers.get("X-Signature", ""), secret)
|
|
294
|
+
|
|
295
|
+
# Option 2: Verify and parse
|
|
296
|
+
event = construct_event(payload, headers, secret)
|
|
297
|
+
|
|
298
|
+
# event.data is the parsed body; event.event_id / event.delivery_id /
|
|
299
|
+
# event.timestamp come from the X-Event-Id / X-Delivery-Id /
|
|
300
|
+
# X-Timestamp headers. See "What lands on your endpoint" below for
|
|
301
|
+
# event.type.
|
|
302
|
+
print(f"Delivery {event.delivery_id} of event {event.event_id}: {event.data}")
|
|
303
|
+
handle_order_completed(event.data)
|
|
304
|
+
|
|
305
|
+
return "OK", 200
|
|
306
|
+
|
|
307
|
+
except RailhookError as e:
|
|
308
|
+
print(f"Webhook verification failed: {e.message}")
|
|
309
|
+
return "Invalid signature", 400
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
### What lands on your endpoint
|
|
313
|
+
|
|
314
|
+
Railhook PUTs the event's **payload** on the wire, not an envelope. This:
|
|
315
|
+
|
|
316
|
+
```python
|
|
317
|
+
client.events.send(Event(type="order.completed", data={"order_id": "ord_1"}))
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
arrives at your endpoint as the ``data`` object alone —
|
|
321
|
+
|
|
322
|
+
```http
|
|
323
|
+
POST /webhooks HTTP/1.1
|
|
324
|
+
Content-Type: application/json
|
|
325
|
+
X-Signature: t=1738000000000,v1=<hex hmac-sha256>
|
|
326
|
+
X-Timestamp: 1738000000000
|
|
327
|
+
X-Event-Id: 6f0e…
|
|
328
|
+
X-Delivery-Id: 91ab…
|
|
329
|
+
X-Sequence-Number: 0
|
|
330
|
+
Idempotency-Key: 6f0e…-<endpoint-id>
|
|
331
|
+
|
|
332
|
+
{"order_id":"ord_1"}
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
So `construct_event` fills `event_id`, `delivery_id` and `timestamp` from the
|
|
336
|
+
headers and `data` from the body, but **`type` is empty**: the event type is
|
|
337
|
+
not on the wire for a default subscription. Route on the payload, on the
|
|
338
|
+
endpoint you registered, or set the subscription's `payload_template` to wrap
|
|
339
|
+
the event so `type` becomes part of the body.
|
|
340
|
+
|
|
341
|
+
The signature is computed over `f"{timestamp}.{raw_body}"` with HMAC-SHA256 and
|
|
342
|
+
the endpoint secret, and the server rejects timestamps more than **300
|
|
343
|
+
seconds** old — verify against the *raw* body bytes, before any JSON parse and
|
|
344
|
+
re-serialize.
|
|
345
|
+
|
|
346
|
+
### FastAPI Example
|
|
347
|
+
|
|
348
|
+
```python
|
|
349
|
+
from fastapi import FastAPI, Request, HTTPException
|
|
350
|
+
from railhook import construct_event, RailhookError
|
|
351
|
+
|
|
352
|
+
app = FastAPI()
|
|
353
|
+
|
|
354
|
+
@app.post("/webhooks")
|
|
355
|
+
async def handle_webhook(request: Request):
|
|
356
|
+
payload = await request.body()
|
|
357
|
+
headers = dict(request.headers)
|
|
358
|
+
|
|
359
|
+
try:
|
|
360
|
+
event = construct_event(
|
|
361
|
+
payload.decode("utf-8"),
|
|
362
|
+
headers,
|
|
363
|
+
os.environ["WEBHOOK_SECRET"],
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
# Process event...
|
|
367
|
+
return {"status": "ok"}
|
|
368
|
+
|
|
369
|
+
except RailhookError as e:
|
|
370
|
+
raise HTTPException(status_code=400, detail=e.message)
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
## Error Handling
|
|
374
|
+
|
|
375
|
+
```python
|
|
376
|
+
from railhook import (
|
|
377
|
+
RailhookError,
|
|
378
|
+
RateLimitError,
|
|
379
|
+
AuthenticationError,
|
|
380
|
+
ValidationError,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
try:
|
|
384
|
+
client.events.send(Event(type="test", data={}))
|
|
385
|
+
except RateLimitError as e:
|
|
386
|
+
# retry_after_ms is milliseconds. e.rate_limit_info.reset is the raw
|
|
387
|
+
# X-RateLimit-Reset header, which the API sends in Unix *seconds*.
|
|
388
|
+
print(f"Rate limited. Retry after {e.retry_after_ms}ms")
|
|
389
|
+
time.sleep(e.retry_after_ms / 1000)
|
|
390
|
+
except AuthenticationError:
|
|
391
|
+
print("Invalid API key")
|
|
392
|
+
except ValidationError as e:
|
|
393
|
+
print(f"Validation failed: {e.field_errors}")
|
|
394
|
+
except RailhookError as e:
|
|
395
|
+
print(f"Error {e.status}: {e.message}")
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
### Error Response Format
|
|
399
|
+
|
|
400
|
+
All API errors return a consistent JSON body:
|
|
401
|
+
|
|
402
|
+
```json
|
|
403
|
+
{
|
|
404
|
+
"error": "error_code",
|
|
405
|
+
"message": "Human-readable description",
|
|
406
|
+
"status": 400,
|
|
407
|
+
"fieldErrors": { "field": "reason" }
|
|
408
|
+
}
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
- **`error`** — machine-readable error code (`snake_case`), always present
|
|
412
|
+
- **`message`** — human-readable description, always present
|
|
413
|
+
- **`status`** — HTTP status code (integer), always present
|
|
414
|
+
- **`fieldErrors`** — field-level validation details (only present for `validation_error`)
|
|
415
|
+
|
|
416
|
+
### Error Codes Reference
|
|
417
|
+
|
|
418
|
+
| HTTP Status | `error` Code | SDK Exception | Description |
|
|
419
|
+
|---|---|---|---|
|
|
420
|
+
| 400 | `validation_error` | `ValidationError` | Invalid request parameters; see `fieldErrors` |
|
|
421
|
+
| 400 | `invalid_request` | `RailhookError` | Malformed or semantically invalid request |
|
|
422
|
+
| 401 | `unauthorized` | `AuthenticationError` | Missing or invalid API key / expired token |
|
|
423
|
+
| 403 | `forbidden` | `RailhookError` | Insufficient permissions for the action |
|
|
424
|
+
| 404 | `not_found` | `NotFoundError` | Requested resource does not exist |
|
|
425
|
+
| 413 | `payload_too_large` | `RailhookError` | Request body exceeds maximum allowed size |
|
|
426
|
+
| 422 | `unprocessable_entity` | `RailhookError` | Valid syntax but violates business rules |
|
|
427
|
+
| 429 | `rate_limit_exceeded` | `RateLimitError` | Too many requests; check `X-RateLimit-*` headers |
|
|
428
|
+
| 500 | `internal_error` | `RailhookError` | Unexpected server error |
|
|
429
|
+
|
|
430
|
+
## Generic Requests
|
|
431
|
+
|
|
432
|
+
As the API expands, you can call any endpoint directly without waiting for SDK updates:
|
|
433
|
+
|
|
434
|
+
```python
|
|
435
|
+
# GET
|
|
436
|
+
schemas = client.get("/api/v1/projects/proj_123/schemas")
|
|
437
|
+
|
|
438
|
+
# GET with query params
|
|
439
|
+
items = client.get("/api/v1/projects/proj_123/items", params={"status": "active"})
|
|
440
|
+
|
|
441
|
+
# POST with body and idempotency key
|
|
442
|
+
result = client.post("/api/v1/some/new/endpoint", body={"key": "value"}, idempotency_key="unique-key")
|
|
443
|
+
|
|
444
|
+
# PUT
|
|
445
|
+
client.put("/api/v1/projects/proj_123/settings", body={"timezone": "UTC"})
|
|
446
|
+
|
|
447
|
+
# PATCH
|
|
448
|
+
client.patch("/api/v1/projects/proj_123/settings", body={"timezone": "UTC"})
|
|
449
|
+
|
|
450
|
+
# DELETE
|
|
451
|
+
client.delete("/api/v1/projects/proj_123/tags/old-tag")
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
All generic methods use the same authentication, error handling, and rate-limit logic as the built-in methods.
|
|
455
|
+
|
|
456
|
+
## Configuration
|
|
457
|
+
|
|
458
|
+
```python
|
|
459
|
+
client = Railhook(
|
|
460
|
+
api_key=os.environ["RAILHOOK_API_KEY"], # Required: Your project API key
|
|
461
|
+
base_url="https://api.example.com", # Optional (default: http://localhost:8080)
|
|
462
|
+
timeout=30, # Optional: Request timeout in seconds (default: 30)
|
|
463
|
+
)
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
### Timeouts and retries
|
|
467
|
+
|
|
468
|
+
`timeout` is passed straight to `requests`; hitting it raises `RailhookError`
|
|
469
|
+
with `code="timeout"` and `status=0`. A connection-level failure raises the
|
|
470
|
+
same class with `code="network_error"`.
|
|
471
|
+
|
|
472
|
+
**The client does not retry.** One SDK call is exactly one HTTP request — no
|
|
473
|
+
backoff, no idempotent replay, and no `urllib3` `Retry` adapter is installed.
|
|
474
|
+
That is deliberate: `events.send` accepts an `idempotency_key`, so a retry
|
|
475
|
+
policy belongs to the caller who knows whether reissuing the request is safe.
|
|
476
|
+
What *is* retried is the delivery itself, by the platform, on the
|
|
477
|
+
subscription's `retry_delays` ladder.
|
|
478
|
+
|
|
479
|
+
## Authentication
|
|
480
|
+
|
|
481
|
+
Every request the client makes carries `X-API-Key: <your key>` — the project
|
|
482
|
+
API key, created in the dashboard or via
|
|
483
|
+
`POST /api/v1/projects/{project_id}/api-keys`. The SDK never sends a bearer
|
|
484
|
+
token and has no login surface: JWT-authenticated endpoints (auth, projects,
|
|
485
|
+
organizations, members, API keys) are not part of it. Bootstrapping a project
|
|
486
|
+
and a key is a one-time step you do with the dashboard, the CLI, or plain
|
|
487
|
+
HTTP.
|
|
488
|
+
|
|
489
|
+
## Type Hints
|
|
490
|
+
|
|
491
|
+
This SDK includes full type hints for better IDE support:
|
|
492
|
+
|
|
493
|
+
```python
|
|
494
|
+
from railhook import (
|
|
495
|
+
Event,
|
|
496
|
+
EventResponse,
|
|
497
|
+
Endpoint,
|
|
498
|
+
Delivery,
|
|
499
|
+
DeliveryStatus,
|
|
500
|
+
)
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
## Development
|
|
504
|
+
|
|
505
|
+
### Running Tests
|
|
506
|
+
|
|
507
|
+
**Local (requires Python 3.8+):**
|
|
508
|
+
```bash
|
|
509
|
+
pip install -e ".[dev]"
|
|
510
|
+
pytest
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
**Docker:**
|
|
514
|
+
```bash
|
|
515
|
+
docker run --rm -v $(pwd):/app -w /app python:3.11-slim sh -c "pip install -e '.[dev]' && pytest"
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
### Live-API smoke check
|
|
519
|
+
|
|
520
|
+
`pytest` stubs the transport, so it cannot see a renamed field. To drive the
|
|
521
|
+
SDK against a real instance:
|
|
522
|
+
|
|
523
|
+
```bash
|
|
524
|
+
make up # from the repo root
|
|
525
|
+
python scripts/live_api_smoke.py # SMOKE_API_BASE_URL overrides the target
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
It registers a throwaway org, walks endpoint → subscription → event →
|
|
529
|
+
deliveries → attempts → incoming, checks each error envelope, and verifies a
|
|
530
|
+
signature the running server itself produced. It is not collected by `pytest`
|
|
531
|
+
(`testpaths = tests`, `python_files = test_*.py`), so the unit suite still
|
|
532
|
+
passes with no backend.
|
|
533
|
+
|
|
534
|
+
## License
|
|
535
|
+
|
|
536
|
+
MIT
|